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
16 changes: 16 additions & 0 deletions .changeset/locale-aware-metadata.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/client": minor
"@objectstack/client-react": minor
"@objectstack/objectql": patch
"@objectstack/rest": patch
"@objectstack/spec": patch
---

Make metadata labels follow the active UI language without a page refresh (#1319).

The client now carries the active locale on every request (`Accept-Language`,
`setLocale`/`getLocale`), the protocol ETag is locale-aware so cached metadata
no longer collides across languages, and the `client-react` metadata hooks
refetch when the locale changes. The `apps/account` console wires its router
locale through so a language switch relabels server-resolved object/field/view
labels in place instead of leaving the UI half-translated until reload.
8 changes: 7 additions & 1 deletion apps/account/src/routes/__root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { useObjectTranslation } from '@object-ui/i18n';
import { ObjectStackProvider } from '@objectstack/client-react';
import { ObjectStackClient } from '@objectstack/client';
import { Toaster } from '@/components/ui/toaster';
Expand DownExpand Up@@ -139,9 +140,14 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
function RootComponent() {
const baseUrl = getApiBaseUrl();
const client = useMemo(() => new ObjectStackClient({ baseUrl }), [baseUrl]);
// Bridge the active UI language into the data/metadata client so server-
// resolved labels follow the in-app language switch without a refresh
// (issue #1319). `useObjectTranslation` re-renders on `languageChanged`,
// so `language` is always current here.
const { language } = useObjectTranslation();

return (
<ObjectStackProvider client={client}>
<ObjectStackProvider client={client} locale={language}>
<SessionProvider>
<RequireAuth>
<Outlet />
Expand Down
51 changes: 47 additions & 4 deletions packages/client-react/src/context.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,16 +7,31 @@
*/

import * as React from 'react';
import { createContext, useContext, ReactNode } from 'react';
import { createContext, useContext, useRef, ReactNode } from 'react';
import { ObjectStackClient } from '@objectstack/client';

export interface ObjectStackProviderProps {
client: ObjectStackClient;
/**
* Active UI locale (BCP-47, e.g. `'zh-CN'`). Keep this in sync with your
* language switcher — the provider pushes it into the client (so requests
* carry `Accept-Language`) and metadata hooks (`useObject`, `useView`,
* `useMetadata`) re-fetch when it changes, so switching language relabels
* the UI without a page refresh (issue #1319).
*/
locale?: string;
children: ReactNode;
}

export const ObjectStackContext = createContext<ObjectStackClient | null>(null);

/**
* Carries the active UI locale separately from the client so existing
* `useContext(ObjectStackContext)` consumers keep receiving the bare client
* (no breaking change to that context's shape).
*/
export const ObjectStackLocaleContext = createContext<string | undefined>(undefined);

/**
* Provider component that makes ObjectStackClient available to all child components
*
Expand All@@ -26,21 +41,49 @@ export const ObjectStackContext = createContext<ObjectStackClient | null>(null);
*
* function App() {
* return (
* <ObjectStackProvider client={client}>
* <ObjectStackProvider client={client} locale={language}>
* <YourComponents />
* </ObjectStackProvider>
* );
* }
* ```
*/
export function ObjectStackProvider({ client, children }: ObjectStackProviderProps) {
export function ObjectStackProvider({ client, locale, children }: ObjectStackProviderProps) {
// Mirror the active locale onto the client so every request carries the
// matching `Accept-Language`.
//
// This MUST run during render, not in a `useEffect`. The child metadata
// hooks read `locale` from context and re-fetch via their own effects, and
// React flushes child effects *before* parent effects — so syncing the
// client in an effect here would update it only after the refetch already
// fired, sending the stale `Accept-Language`. Render runs parent-before-
// child, so updating the client here guarantees it is current before any
// child fetches. The ref keeps the write idempotent across re-renders /
// StrictMode double-invokes.
const synced = useRef<{ client: ObjectStackClient; locale: string | undefined } | null>(null);
if (synced.current?.client !== client || synced.current?.locale !== locale) {
synced.current = { client, locale };
client.setLocale?.(locale);
}

return (
<ObjectStackContext.Provider value={client}>
{children}
<ObjectStackLocaleContext.Provider value={locale}>
{children}
</ObjectStackLocaleContext.Provider>
</ObjectStackContext.Provider>
);
}

/**
* Hook to read the active UI locale provided to {@link ObjectStackProvider}.
* Returns `undefined` when no locale was supplied. Metadata hooks fold this
* into their fetch dependencies so a locale change triggers a re-fetch.
*/
export function useObjectStackLocale(): string | undefined {
return useContext(ObjectStackLocaleContext);
}

/**
* Hook to access the ObjectStackClient instance from context
*
Expand Down
2 changes: 2 additions & 0 deletions packages/client-react/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,9 @@
export {
ObjectStackProvider,
ObjectStackContext,
ObjectStackLocaleContext,
useClient,
useObjectStackLocale,
type ObjectStackProviderProps
} from './context';

Expand Down
17 changes: 13 additions & 4 deletions packages/client-react/src/metadata-hooks.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
*/

import { useState, useEffect, useCallback } from 'react';
import { useClient } from './context';
import { useClient, useObjectStackLocale } from './context';

/**
* Metadata query options
Expand DownExpand Up@@ -70,6 +70,10 @@ export function useObject(
options: UseMetadataOptions = {}
): UseMetadataResult {
const client = useClient();
// Active UI locale: object/field labels are translated server-side, so a
// language switch must re-fetch (it is *not* reactive via i18next). Folding
// `locale` into the fetch deps below triggers that re-fetch (issue #1319).
const locale = useObjectStackLocale();
const [data, setData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand DownExpand Up@@ -123,7 +127,7 @@ export function useObject(
} finally {
setIsLoading(false);
}
}, [client, objectName, enabled, useCache, ifNoneMatch, ifModifiedSince, etag, data, onSuccess, onError]);
}, [client, objectName, locale, enabled, useCache, ifNoneMatch, ifModifiedSince, etag, data, onSuccess, onError]);

useEffect(() => {
fetchMetadata();
Expand DownExpand Up@@ -168,6 +172,8 @@ export function useView(
options: UseMetadataOptions = {}
): UseMetadataResult {
const client = useClient();
// View headers/labels are translated server-side — re-fetch on locale change.
const locale = useObjectStackLocale();
const [data, setData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand All@@ -191,7 +197,7 @@ export function useView(
} finally {
setIsLoading(false);
}
}, [client, objectName, viewType, enabled, onSuccess, onError]);
}, [client, objectName, viewType, locale, enabled, onSuccess, onError]);

useEffect(() => {
fetchView();
Expand DownExpand Up@@ -271,6 +277,9 @@ export function useMetadata<T = any>(
options: Omit<UseMetadataOptions, 'useCache' | 'ifNoneMatch' | 'ifModifiedSince'> = {}
): UseMetadataResult<T> {
const client = useClient();
// Custom fetchers commonly read server-translated metadata too — refetch on
// locale change so their labels follow the active language.
const locale = useObjectStackLocale();
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand All@@ -294,7 +303,7 @@ export function useMetadata<T = any>(
} finally {
setIsLoading(false);
}
}, [client, fetcher, enabled, onSuccess, onError]);
}, [client, fetcher, locale, enabled, onSuccess, onError]);

useEffect(() => {
fetchMetadata();
Expand Down
46 changes: 46 additions & 0 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -965,3 +965,49 @@ describe('ScopedProjectClient', () => {
expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001');
});
});

// ==========================================
// Locale propagation (issue #1319)
// ==========================================

describe('ObjectStackClient locale → Accept-Language', () => {
/** Pull the headers object from the most recent fetch call. */
function lastHeaders(fetchMock: ReturnType<typeof vi.fn>): Record<string, string> {
const call = fetchMock.mock.calls.at(-1);
return (call?.[1]?.headers ?? {}) as Record<string, string>;
}

it('sends no Accept-Language when no locale is configured', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: {} });
await client.meta.getItem('object', 'customer');
expect(lastHeaders(fetchMock)['Accept-Language']).toBeUndefined();
});

it('sends the configured locale as Accept-Language', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: {} }), headers: new Headers(),
});
const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock,
locale: 'zh-CN',
});
await client.meta.getItem('object', 'customer');
expect(lastHeaders(fetchMock)['Accept-Language']).toBe('zh-CN');
});

it('setLocale() updates the header on subsequent requests', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: {} });
await client.meta.getItem('object', 'customer');
expect(lastHeaders(fetchMock)['Accept-Language']).toBeUndefined();

client.setLocale('zh-CN');
await client.meta.getItem('object', 'customer');
expect(lastHeaders(fetchMock)['Accept-Language']).toBe('zh-CN');
expect(client.getLocale()).toBe('zh-CN');

client.setLocale(undefined);
await client.meta.getItem('object', 'customer');
expect(lastHeaders(fetchMock)['Accept-Language']).toBeUndefined();
});
});
39 changes: 39 additions & 0 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,17 @@ export interface ClientConfig {
* @see docs/adr/0002-project-database-isolation.md
*/
environmentId?: string;
/**
* Active UI locale (BCP-47, e.g. `'zh-CN'`). When set, the client sends
* it as an `Accept-Language` header on every request so the server
* resolves metadata translations (object/field labels, view headers,
* action text) for the *in-app* language rather than the browser default.
*
* Apps should keep this in sync with their language switcher via
* {@link ObjectStackClient.setLocale} so switching language re-fetches
* localized metadata without a page refresh (issue #1319).
*/
locale?: string;
}

/**
Expand DownExpand Up@@ -235,6 +246,7 @@ export class ObjectStackClient {
private baseUrl: string;
private token?: string;
private environmentId?: string;
private locale?: string;
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
private discoveryInfo?: DiscoveryResult;
private logger: Logger;
Expand All@@ -244,6 +256,7 @@ export class ObjectStackClient {
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.token = config.token;
this.environmentId = config.environmentId;
this.locale = config.locale;
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);

// Initialize logger
Expand DownExpand Up@@ -1580,6 +1593,25 @@ export class ObjectStackClient {
return this.environmentId;
}

/**
* Update the active UI locale used for subsequent requests. Apps should
* call this from their language switcher so server-translated metadata
* (object/field labels, view headers, action text) follows the in-app
* language without a page refresh. Pass `undefined` to clear and fall
* back to the browser's `Accept-Language` (issue #1319).
*/
setLocale(locale: string | undefined): void {
this.locale = locale;
this.logger.debug('Active locale changed', { locale });
}

/**
* Current active UI locale (if set).
*/
getLocale(): string | undefined {
return this.locale;
}

/**
* Authentication Services
*/
Expand DownExpand Up@@ -3164,6 +3196,13 @@ export class ObjectStackClient {
headers['X-Environment-Id'] = this.environmentId;
}

// Carry the in-app locale so the server resolves metadata translations
// for the chosen UI language. Don't clobber a caller-supplied header
// (case-insensitive check — `headers` is spread from options above).
if (this.locale && !Object.keys(headers).some((h) => h.toLowerCase() === 'accept-language')) {
headers['Accept-Language'] = this.locale;
}

const res = await this.fetchImpl(url, { ...options, headers });

this.logger.debug('HTTP response', {
Expand Down
56 changes: 56 additions & 0 deletions packages/objectql/src/protocol-meta.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -608,6 +608,62 @@
});
});

// ═══════════════════════════════════════════════════════════════
// getMetaItemCached — locale-aware ETag (#1319)
// ═══════════════════════════════════════════════════════════════
//
// The REST layer translates the response body AFTER this validator runs,
// so the ETag must vary by locale — otherwise a language switch matches
// the prior `If-None-Match` and returns a stale-locale 304.

describe('getMetaItemCached locale-aware ETag', () => {
const sampleObject = { name: 'customer', label: 'Customer' };

beforeEach(() => {
// Serve the item from a sys_metadata overlay row so the read
// succeeds without tripping SchemaRegistry validation on a
// deliberately-minimal object def.
mockEngine.findOne.mockResolvedValue({
type: 'object',
name: 'customer',
state: 'active',
metadata: JSON.stringify(sampleObject),
});
});

it('produces distinct ETags for distinct locales', async () => {
const en = await protocol.getMetaItemCached({ type: 'object', name: 'customer', locale: 'en' });
const zh = await protocol.getMetaItemCached({ type: 'object', name: 'customer', locale: 'zh-CN' });
expect(en.etag?.value).toBeTruthy();
expect(zh.etag?.value).toBeTruthy();
expect(en.etag?.value).not.toBe(zh.etag?.value);
});

it('returns a fresh 200 (not 304) when the cached ETag is from another locale', async () => {
const en = await protocol.getMetaItemCached({ type: 'object', name: 'customer', locale: 'en' });
// Client re-requests after switching to zh-CN, replaying the en ETag.
const zh = await protocol.getMetaItemCached({
type: 'object',
name: 'customer',
locale: 'zh-CN',
cacheRequest: { ifNoneMatch: `"${en.etag?.value}"` },
});
expect(zh.notModified).toBe(false);
expect(zh.data).toBeDefined();
});

it('still returns 304 when the same locale revalidates with a matching ETag', async () => {
const first = await protocol.getMetaItemCached({ type: 'object', name: 'customer', locale: 'zh-CN' });
const second = await protocol.getMetaItemCached({
type: 'object',
name: 'customer',
locale: 'zh-CN',
cacheRequest: { ifNoneMatch: `"${first.etag?.value}"` },
});
expect(second.notModified).toBe(true);
});
});

// ═══════════════════════════════════════════════════════════════
// getMetaItems — registry-first, DB fallback
// ═══════════════════════════════════════════════════════════════
Expand DownExpand Up@@ -822,7 +878,7 @@

expect(result.loaded).toBe(2);
expect(registry.getItem('app', 'test_app')).toEqual(sampleApp);
expect(registry.getItem('object', 'task')).toEqual(objDef);

Check failure on line 881 in packages/objectql/src/protocol-meta.test.ts

View workflow job for this annotation

GitHub Actions/ Test Core

src/protocol-meta.test.ts > ObjectStackProtocolImplementation - Metadata Persistence > loadMetaFromDb > should load records of different types

AssertionError: expected { name: 'task', label: 'Task', …(4) } to deeply equal { name: 'task', label: 'Task', …(2) } - Expected + Received { + "_packageId": "sys_metadata", + "_provenance": "package", "fields": {}, "label": "Task", "name": "task", "systemFields": false, } ❯ src/protocol-meta.test.ts:881:56
});
});

Expand Down
Loading
Loading