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
12 changes: 12 additions & 0 deletions .changeset/27625-localization-read-states-chosen-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/console': patch
---

fix(console): a language picked on the sign-in page reaches date and number formatting

Clause-②: no
Metadata-protocol: none

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.

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.
105 changes: 104 additions & 1 deletion ui/apps/console/src/LocalizationFetchProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { act } from 'react';
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useDisplayLocale, useLocalization } from '@object-ui/i18n';
import {
I18nProvider,
LOCALE_SEED_STORAGE_KEY,
LOCALE_STORAGE_KEY,
useDisplayLocale,
useLocalization,
useObjectTranslation,
} from '@object-ui/i18n';
import { formatDate, formatDateTime, setDisplayTimeZone } from '@object-ui/core';
import { notifyDataChanged } from '@object-ui/react';
// Test-only and off the `@object-ui/types` barrel; the vitest alias makes this
Expand Down Expand Up @@ -358,4 +365,100 @@ describe('LocalizationFetchProvider', () => {
expect(fetchMock()).toHaveBeenCalledTimes(1);
});
});

describe('states the device\'s chosen language (objectstack#27625)', () => {
const fetchMock = () => globalThis.fetch as never as ReturnType<typeof vi.fn>;
/** The `Accept-Language` the Nth request stated; `null` = none, the browser's own goes out. */
const statedOn = (n: number) =>
(fetchMock().mock.calls[n]?.[1]?.headers as Record<string, string> | undefined)?.['Accept-Language'] ?? null;

let switchTo: (code: string) => Promise<void> = async () => {};

/** The date face a grid cell paints, plus a handle on the language switch. */
function DisplayProbe() {
const { changeLanguage } = useObjectTranslation();
switchTo = changeLanguage;
const locale = useDisplayLocale();
return (
<span data-testid="display">
{`${locale}|${formatDate('2026-10-11', undefined, { locale, alwaysShowYear: true })}`}
</span>
);
}

function renderUnderLanguage(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<LocalizationFetchProvider endpoint={ENDPOINT}>
<DisplayProbe />
</LocalizationFetchProvider>
</I18nProvider>,
);
}

beforeEach(() => { window.localStorage.clear(); authUser = { id: 'u1' }; });
afterEach(() => {
authUser = null;
window.localStorage.clear();
resetInflightGetsForTesting();
});

it('a language picked before sign-in is the one the read states, and the dates follow it', async () => {
// The sign-in page's pick: the explicit-choice slot, no `sys_user.locale`.
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'zh');
// The server answers the `Accept-Language` rung with what it was told.
fetchMock().mockImplementation(async (_url: string, init: RequestInit) =>
ok({
authenticated: true,
currency: null,
locale: (init.headers as Record<string, string>)['Accept-Language'] ?? 'en-US',
timezone: 'UTC',
}),
);

renderUnderLanguage('zh');

await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh|2026年10月11日'));
expect(statedOn(0)).toBe('zh');
expect(fetchMock()).toHaveBeenCalledTimes(1);
});

it('with no choice on this device, the read states nothing and the browser\'s header goes out', async () => {
fetchMock().mockResolvedValue(ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }));

renderUnderLanguage('en');

await waitFor(() => expect(fetchMock()).toHaveBeenCalledTimes(1));
expect(statedOn(0)).toBeNull();
});

it('a change of the chosen language reads again, stating the new one', async () => {
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'en');
fetchMock()
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'en', timezone: 'UTC' }))
.mockResolvedValueOnce(ok({ authenticated: true, currency: null, locale: 'zh', timezone: 'UTC' }));

renderUnderLanguage('en');
await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('en|Oct 11, 2026'));

await act(async () => { await switchTo('zh'); });

await waitFor(() => expect(screen.getByTestId('display')).toHaveTextContent('zh|2026年10月11日'));
expect(fetchMock()).toHaveBeenCalledTimes(2);
expect(statedOn(0)).toBe('en');
expect(statedOn(1)).toBe('zh');
});

it('a stored sys_user.locale still wins over the stated language', async () => {
window.localStorage.setItem(LOCALE_STORAGE_KEY, 'zh');
// The column's rung answers first, whatever the header says.
fetchMock().mockResolvedValue(ok({ authenticated: true, currency: null, locale: 'de-DE', timezone: 'UTC' }));

renderUnderLanguage('zh');

await waitFor(() => expect(screen.getByTestId('display').textContent).toMatch(/^de-DE\|/));
expect(screen.getByTestId('display').textContent).not.toContain('年');
expect(statedOn(0)).toBe('zh');
});
});
});
39 changes: 36 additions & 3 deletions ui/apps/console/src/LocalizationFetchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,35 @@
* profile page's. That re-read starts after the write has settled, and it does
* not join a request still in flight from before it (the shared in-flight
* registry would hand back the stale answer), so it is the one that lands.
*
* ## State the device's chosen language (objectstack#27625)
*
* Every other console API call carries the active UI language as its
* `Accept-Language` (`createAuthenticatedFetch` stamps `<html lang>`), but this
* read goes through `sharedGetJson`'s bare `fetch`, so it carried the BROWSER's
* header instead. A user with no `sys_user.locale` who picked 中文 on the
* sign-in page therefore got the `Accept-Language` rung answered in the
* browser's language, and every date cell said `Nov 18` under Chinese headers,
* on every load.
*
* So when the device holds an explicit language choice, this read states that
* language. The server's rungs are unchanged: a stored `sys_user.locale` still
* answers first, and the header only decides the rung below it. Without a
* choice the browser's header stays: that is exactly the boot on which
* `seedTenantLanguage()` asks this endpoint too, and the two share one request
* only while their headers match (objectui#5544). The stated language is part
* of the request, so a change of it reads again, and the new read cannot join
* one still in flight under the old language: the in-flight key includes the
* headers.
*/
import { useEffect, useState } from 'react';
import { LocalizationProvider, cacheLanguageSeed, type LocalizationValue } from '@object-ui/i18n';
import {
LocalizationProvider,
cacheLanguageSeed,
readStoredLanguage,
useObjectTranslation,
type LocalizationValue,
} from '@object-ui/i18n';
import { getSessionOwnerChangeCount, useAuth } from '@object-ui/auth';
import { useDataInvalidation } from '@object-ui/react';
import {
Expand Down Expand Up @@ -87,6 +113,10 @@ export function LocalizationFetchProvider({
const userId = user?.id ? String(user.id) : undefined;
// Bumps on every write to the signed-in user's own row (objectstack#27498).
const userRowWrites = useDataInvalidation(userId ? 'sys_user' : undefined, userId);
// The device's chosen UI language, stated as this read's `Accept-Language`;
// none chosen, the browser's own header goes out (objectstack#27625).
const { language } = useObjectTranslation();
const statedLanguage = readStoredLanguage() ? language : undefined;

useEffect(() => {
let cancelled = false;
Expand All @@ -113,7 +143,10 @@ export function LocalizationFetchProvider({
// resolving one of them empty.
const json = await sharedGetJson<MeLocalizationResponse>(endpoint, {
credentials: 'include',
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json',
...(statedLanguage ? { 'Accept-Language': statedLanguage } : {}),
},
signal: reread?.signal,
});
// Refresh the UI-language seed cache (objectui#4035) — the
Expand Down Expand Up @@ -164,7 +197,7 @@ export function LocalizationFetchProvider({
cancelled = true;
reread?.abort();
};
}, [endpoint, userRowWrites]);
}, [endpoint, userRowWrites, statedLanguage]);

return <LocalizationProvider value={value}>{children}</LocalizationProvider>;
}
Loading