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
30 changes: 30 additions & 0 deletions .changeset/i18n-language-switch.md
Original file line number Diff line number Diff line change
@@ -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
`<html lang>` 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`.
33 changes: 31 additions & 2 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
if (adapter && lastLanguage.current !== null && lastLanguage.current !== language) {
adapter.clearCache?.();
}
if (adapter) lastLanguage.current = language;

if (!adapter) return <LoadingFallback />;
// Expose the adapter via SchemaRendererContext so descendant hooks like
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
return (
<SchemaRendererProvider dataSource={adapter}>
<MetadataProvider adapter={adapter}>
<MetadataProvider key={language} adapter={adapter}>
<UserStateBridge />
{children}
</MetadataProvider>
Expand Down
86 changes: 86 additions & 0 deletions packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx
Original file line number Diff line number Diff line change
@@ -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` ← `<html lang>` 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 <html lang> 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 <html lang> 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');
});
});
23 changes: 18 additions & 5 deletions packages/auth/src/createAuthenticatedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,31 @@ export const ActiveOrganizationStorage = {
export function createAuthenticatedFetch(): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
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 `<html lang>` 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 });
};
}
7 changes: 6 additions & 1 deletion packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
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);

Expand Down
48 changes: 47 additions & 1 deletion packages/i18n/src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof vi.spyOn>;

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();
});
});
});
62 changes: 62 additions & 0 deletions packages/i18n/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> } | 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<string, unknown>,
) => void {
const seen = new Set<string>();
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}.`,
);
};
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/useObjectLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
Loading