From 18d0339e61988ed686c5cee50f70051433cd4f6a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 30 May 2026 18:37:39 +0800 Subject: [PATCH] fix(i18n): relabel metadata UI on language switch without refresh (#1319) Renderers cache server-resolved metadata labels (object/field/view labels, action-dialog text) by object name and never refetch on a language change, so a switch left the UI half-translated until a hard refresh. - auth: fold the active into Accept-Language on API calls so the server resolves metadata in the new locale (never clobbers an explicit header) - app-shell: ConnectedShellInner clears the adapter's locale-blind metadata cache in the render phase and remounts the metadata subtree via key={language} so every renderer refetches; the adapter/connection are preserved (in-app relabel, not a reconnect) - i18n: dev-mode missing-key warnings (createI18n warnMissingKeys, deduped handler) that stay silent for useObjectLabel's intentional convention probes Renderer half of #1319; pairs with the framework-side locale-aware metadata. --- .changeset/i18n-language-switch.md | 30 +++++++ .../app-shell/src/console/ConsoleShell.tsx | 33 ++++++- .../createAuthenticatedFetch.test.tsx | 86 +++++++++++++++++++ packages/auth/src/createAuthenticatedFetch.ts | 23 +++-- packages/data-objectstack/src/index.ts | 7 +- packages/i18n/src/__tests__/i18n.test.ts | 48 ++++++++++- packages/i18n/src/i18n.ts | 62 +++++++++++++ packages/i18n/src/useObjectLabel.ts | 6 +- 8 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 .changeset/i18n-language-switch.md create mode 100644 packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx diff --git a/.changeset/i18n-language-switch.md b/.changeset/i18n-language-switch.md new file mode 100644 index 000000000..19ea793ce --- /dev/null +++ b/.changeset/i18n-language-switch.md @@ -0,0 +1,30 @@ +--- +'@object-ui/i18n': minor +'@object-ui/auth': minor +'@object-ui/app-shell': patch +--- + +Relabel metadata-driven UI on a language switch without a page refresh (#1319) + +Switching the UI language left server-resolved metadata labels (object/field/ +view labels, action-dialog text) in the old language until a hard refresh, +because renderers cache those labels by object name and never refetch on a +language change. + +**`@object-ui/auth`** — `createAuthenticatedFetch` now folds the active +`` into `Accept-Language` on API calls (never clobbering an explicit +header), so a switch carries the new locale on every subsequent request. + +**`@object-ui/app-shell`** — `ConnectedShellInner` drops the adapter's +locale-blind metadata cache in the render phase and remounts the metadata +subtree via `key={language}`, so every renderer refetches in the new locale. +The adapter and its connection sit above the key and are preserved — an in-app +relabel, not a reconnect. + +**`@object-ui/i18n`** — dev-mode missing-key warnings: `createI18n` gains +`warnMissingKeys` (default on outside production) wiring a deduped i18next +`missingKeyHandler`. `useObjectLabel`'s convention-key probes are flagged so +their intentional misses (which fall back to server metadata) stay silent. + +Pairs with the framework-side locale-aware metadata changes in +`@objectstack/client` / `@objectstack/objectql` / `@objectstack/rest`. diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 5ee6390b3..ed099f7ad 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -10,9 +10,10 @@ * apps/console/src/App.tsx for one with custom system routes + CreateApp. */ -import { Suspense, useEffect, type ReactNode } from 'react'; +import { Suspense, useEffect, useRef, type ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { AuthGuard, useAuth } from '@object-ui/auth'; +import { useObjectTranslation } from '@object-ui/i18n'; import { SchemaRendererProvider } from '@object-ui/react'; import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack'; import { AdapterProvider, useAdapter } from '../providers/AdapterProvider'; @@ -79,12 +80,40 @@ export function ConnectedShell({ children }: { children: ReactNode }) { function ConnectedShellInner({ children }: { children: ReactNode }) { const adapter = useAdapter(); + const { language } = useObjectTranslation(); + + // ── Language switch → relabel without a page refresh (issue #1319) ── + // + // Static UI strings already flip reactively through react-i18next, and + // metadata labels that have a translation key resolve client-side via + // `useObjectLabel`. The gap is *server-resolved* labels (object/field/view + // labels, action-dialog text) with no client key: renderers fetch those into + // local state keyed by object name, so they never re-fetch on a language + // change and the UI ends up half-translated until a hard refresh. + // + // We close the gap in two moves, both keyed off `language`: + // 1. Drop the adapter's locale-blind metadata cache (render phase, before + // any child re-fetches) so the next read hits the network. This runs in + // render — not an effect — because the remount below mounts children + // (and their fetch effects) before a parent effect here would fire, so + // an effect-based clear would race and serve the stale entry. + // 2. Remount the metadata subtree via `key={language}` so every renderer's + // fetch effect re-runs; combined with the new `Accept-Language` header + // (see `createAuthenticatedFetch`) the refetch comes back in the new + // locale. The adapter — and its live connection — sits above the key and + // is preserved, so this is an in-app relabel, not a reconnect. + const lastLanguage = useRef(null); + if (adapter && lastLanguage.current !== null && lastLanguage.current !== language) { + adapter.clearCache?.(); + } + if (adapter) lastLanguage.current = language; + if (!adapter) return ; // Expose the adapter via SchemaRendererContext so descendant hooks like // useDiscovery() (used to gate the global AI chatbot) can resolve it. return ( - + {children} diff --git a/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx b/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx new file mode 100644 index 000000000..60aadfc16 --- /dev/null +++ b/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx @@ -0,0 +1,86 @@ +/** + * Tests for createAuthenticatedFetch — header injection (auth, tenant, locale). + * + * Lives as a `.test.tsx` so it runs under happy-dom (the repo routes `.test.ts` + * to the node environment), giving us a real `document` for the + * `Accept-Language` ← `` behaviour added for issue #1319. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createAuthenticatedFetch, ActiveOrganizationStorage } from '../createAuthenticatedFetch'; +import { TokenStorage } from '../createAuthClient'; + +const API_URL = 'http://localhost/api/v1/meta/object/account'; + +/** Stub the global fetch and capture the Headers it was called with. */ +function stubFetch() { + const calls: Array<{ url: string; headers: Headers }> = []; + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + calls.push({ url, headers: new Headers(init?.headers) }); + return new Response('{}', { status: 200 }); + }); + vi.stubGlobal('fetch', mock); + return calls; +} + +describe('createAuthenticatedFetch', () => { + beforeEach(() => { + ActiveOrganizationStorage.clear(); + vi.spyOn(TokenStorage, 'get').mockReturnValue(null); + document.documentElement.removeAttribute('lang'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('injects the Bearer token on API calls', async () => { + vi.spyOn(TokenStorage, 'get').mockReturnValue('tok123'); + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL); + expect(calls[0].headers.get('Authorization')).toBe('Bearer tok123'); + }); + + it('does not inject the token on non-API URLs', async () => { + vi.spyOn(TokenStorage, 'get').mockReturnValue('tok123'); + const calls = stubFetch(); + await createAuthenticatedFetch()('http://localhost/static/logo.png'); + expect(calls[0].headers.get('Authorization')).toBeNull(); + }); + + it('injects the tenant header when an active organization is set', async () => { + ActiveOrganizationStorage.set('org-42'); + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL); + expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-42'); + }); + + it('folds the active into Accept-Language on API calls (#1319)', async () => { + document.documentElement.lang = 'zh-CN'; + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL); + expect(calls[0].headers.get('Accept-Language')).toBe('zh-CN'); + }); + + it('does not set Accept-Language when is empty', async () => { + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL); + expect(calls[0].headers.get('Accept-Language')).toBeNull(); + }); + + it('does not set Accept-Language on non-API URLs', async () => { + document.documentElement.lang = 'zh-CN'; + const calls = stubFetch(); + await createAuthenticatedFetch()('http://localhost/static/logo.png'); + expect(calls[0].headers.get('Accept-Language')).toBeNull(); + }); + + it('never clobbers an Accept-Language the caller set explicitly', async () => { + document.documentElement.lang = 'zh-CN'; + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL, { headers: { 'Accept-Language': 'ja' } }); + expect(calls[0].headers.get('Accept-Language')).toBe('ja'); + }); +}); diff --git a/packages/auth/src/createAuthenticatedFetch.ts b/packages/auth/src/createAuthenticatedFetch.ts index 5ddf41a1a..ab7178e63 100644 --- a/packages/auth/src/createAuthenticatedFetch.ts +++ b/packages/auth/src/createAuthenticatedFetch.ts @@ -76,18 +76,31 @@ export const ActiveOrganizationStorage = { export function createAuthenticatedFetch(): (input: RequestInfo | URL, init?: RequestInit) => Promise { return async (input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const isApiCall = /\/api\//i.test(url); const token = TokenStorage.get(); - if (token) { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (/\/api\//i.test(url)) { - headers.set('Authorization', `Bearer ${token}`); - } + if (token && isApiCall) { + headers.set('Authorization', `Bearer ${token}`); } // Inject tenant header for multi-tenant routing const activeOrgId = ActiveOrganizationStorage.get(); if (activeOrgId) { headers.set('X-Tenant-ID', activeOrgId); } + // Inject the active UI language so the server resolves metadata labels + // (object/field/view labels, action-dialog text) in the right locale. The + // i18n provider keeps `` in sync with the in-app language + // switcher, so reading it here means a language switch carries the new + // `Accept-Language` on every subsequent request — closing the gap where + // server-resolved labels stayed in the old language until a page refresh + // (issue #1319). We only fold it in for our own API calls, and never + // clobber an `Accept-Language` the caller set explicitly. + if (isApiCall && !headers.has('Accept-Language') && typeof document !== 'undefined') { + const lang = document.documentElement.lang; + if (lang) { + headers.set('Accept-Language', lang); + } + } return fetch(input, { ...init, headers }); }; } diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index a04e30cc3..03d5b9291 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -1233,7 +1233,12 @@ export class ObjectStackAdapter implements DataSource { await this.connect(); try { - // Use cache with automatic fetching + // Use cache with automatic fetching. The cache is keyed by object name + // only (locale-independent); a language switch wipes it wholesale via + // `clearCache()` so the next read re-fetches in the new locale — see the + // shell's locale remount (issue #1319). Keeping the key locale-free here + // means a metadata *write* still invalidates the single entry it knows + // about, without having to fan out across every cached locale. const schema = await this.metadataCache.get(objectName, async () => { const result: any = await this.client.meta.getItem('object', objectName); diff --git a/packages/i18n/src/__tests__/i18n.test.ts b/packages/i18n/src/__tests__/i18n.test.ts index 05a77f25c..3c2d95fd7 100644 --- a/packages/i18n/src/__tests__/i18n.test.ts +++ b/packages/i18n/src/__tests__/i18n.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { I18N_PROBE_FLAG } from '../i18n'; import { createI18n, getDirection, @@ -252,4 +253,49 @@ describe('@object-ui/i18n', () => { expect(result).toContain('M'); // e.g. 1.2M }); }); + + // ── M2: dev-mode missing-key warnings (issue #1319) ──────────────────────── + describe('warnMissingKeys', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('warns once when a static key is missing', () => { + const i18n = createI18n({ detectBrowserLanguage: false, warnMissingKeys: true }); + i18n.t('totally.missing.key'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('totally.missing.key'); + }); + + it('dedupes repeated lookups of the same missing key', () => { + const i18n = createI18n({ detectBrowserLanguage: false, warnMissingKeys: true }); + i18n.t('repeat.me'); + i18n.t('repeat.me'); + i18n.t('repeat.me'); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('does not warn for resolved keys', () => { + const i18n = createI18n({ detectBrowserLanguage: false, warnMissingKeys: true }); + i18n.t('common.save'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('stays silent for flagged convention-key probes', () => { + const i18n = createI18n({ detectBrowserLanguage: false, warnMissingKeys: true }); + i18n.t('crm.objects.lead.label', { defaultValue: '', [I18N_PROBE_FLAG]: true }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn at all when disabled', () => { + const i18n = createI18n({ detectBrowserLanguage: false, warnMissingKeys: false }); + i18n.t('another.missing.key'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index 68f1bc75a..70178feef 100644 --- a/packages/i18n/src/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -23,6 +23,63 @@ export interface I18nConfig { prefix?: string; suffix?: string; }; + /** + * Warn (once per key) in the dev console when a translation key is missing + * and the UI falls back to the key/defaultValue. Helps catch un-translated + * static strings while iterating. Defaults to ON outside production builds. + * + * Convention-key probes from `useObjectLabel` (object/field/view labels that + * intentionally fall back to server metadata) are excluded — they are not + * real "missing keys", just speculative lookups. + */ + warnMissingKeys?: boolean; +} + +/** + * Internal `t()` option flag set by `useObjectLabel` on its convention-key + * probes. The missing-key handler skips any lookup carrying this flag, so the + * deliberate object/field/view label probes (which usually miss and fall back + * to server metadata) never surface as dev warnings. Not part of the public + * API — shared between `i18n.ts` and `useObjectLabel.ts` to avoid drift. + */ +export const I18N_PROBE_FLAG = '__ouiLabelProbe'; + +// Module-scoped ambient: this browser-targeted package omits @types/node, but +// bundlers (Vite/esbuild) statically replace `process.env.NODE_ENV`, so the +// reference is safe and tree-shakes to a constant in production. +declare const process: { env: Record } | undefined; + +/** True outside production builds (bundlers statically replace this). */ +function isDevEnv(): boolean { + return typeof process === 'undefined' || process.env.NODE_ENV !== 'production'; +} + +/** + * Build a dev-only i18next `missingKeyHandler`. Dedupes by language+key so a + * missing string warns once, not on every re-render, and stays silent for the + * convention-key probes flagged with {@link I18N_PROBE_FLAG}. + */ +function createMissingKeyHandler(): ( + lngs: readonly string[], + ns: string, + key: string, + fallbackValue: string, + updateMissing: boolean, + options: Record, +) => void { + const seen = new Set(); + return (lngs, _ns, key, fallbackValue, _updateMissing, options) => { + if (options && options[I18N_PROBE_FLAG]) return; + const lng = Array.isArray(lngs) ? lngs[0] : String(lngs ?? ''); + const dedupeKey = `${lng}:${key}`; + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); + const fb = fallbackValue ? `"${fallbackValue}"` : 'the key itself'; + // eslint-disable-next-line no-console + console.warn( + `[object-ui i18n] Missing translation for "${key}" (language "${lng}") — falling back to ${fb}.`, + ); + }; } /** @@ -35,6 +92,7 @@ export function createI18n(config: I18nConfig = {}): I18nInstance { resources = {}, detectBrowserLanguage = true, interpolation, + warnMissingKeys = isDevEnv(), } = config; // Merge built-in locales with user-provided resources @@ -81,6 +139,10 @@ export function createI18n(config: I18nConfig = {}): I18nInstance { ...interpolation, }, returnNull: false, + // Dev-only: surface un-translated static keys in the console (deduped, + // and silent for useObjectLabel's intentional convention-key probes). + saveMissing: warnMissingKeys, + missingKeyHandler: warnMissingKeys ? createMissingKeyHandler() : undefined, react: { useSuspense: false, }, diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index a19833fb4..96b0bd928 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -19,6 +19,7 @@ import { useMemo } from 'react'; import { useObjectTranslation } from './provider'; +import { I18N_PROBE_FLAG } from './i18n'; /** * Built-in Object UI top-level locale keys — not app namespaces. @@ -94,7 +95,10 @@ export function useObjectLabel() { for (const ns of namespaces) { for (const suffix of suffixList) { const key = `${ns}.${suffix}`; - const translated = t(key, { defaultValue: '' }); + // `I18N_PROBE_FLAG` marks this as a speculative convention lookup so + // the dev missing-key warner stays silent when it (expectedly) misses + // and we fall back to the server-resolved label. + const translated = t(key, { defaultValue: '', [I18N_PROBE_FLAG]: true }); if (translated && translated !== key && translated !== '') { return translated; }