diff --git a/.changeset/locale-aware-metadata.md b/.changeset/locale-aware-metadata.md new file mode 100644 index 0000000000..6e1cf51f90 --- /dev/null +++ b/.changeset/locale-aware-metadata.md @@ -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. diff --git a/apps/account/src/routes/__root.tsx b/apps/account/src/routes/__root.tsx index 035db20664..12e9740cfd 100644 --- a/apps/account/src/routes/__root.tsx +++ b/apps/account/src/routes/__root.tsx @@ -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'; @@ -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 ( - + diff --git a/packages/client-react/src/context.tsx b/packages/client-react/src/context.tsx index 431062ca02..c5739e1818 100644 --- a/packages/client-react/src/context.tsx +++ b/packages/client-react/src/context.tsx @@ -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(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(undefined); + /** * Provider component that makes ObjectStackClient available to all child components * @@ -26,21 +41,49 @@ export const ObjectStackContext = createContext(null); * * function App() { * return ( - * + * * * * ); * } * ``` */ -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 ( - {children} + + {children} + ); } +/** + * 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 * diff --git a/packages/client-react/src/index.tsx b/packages/client-react/src/index.tsx index e7ae8d0f05..5e46a95938 100644 --- a/packages/client-react/src/index.tsx +++ b/packages/client-react/src/index.tsx @@ -15,7 +15,9 @@ export { ObjectStackProvider, ObjectStackContext, + ObjectStackLocaleContext, useClient, + useObjectStackLocale, type ObjectStackProviderProps } from './context'; diff --git a/packages/client-react/src/metadata-hooks.tsx b/packages/client-react/src/metadata-hooks.tsx index bfaa3dd5ee..b31f4cc1b7 100644 --- a/packages/client-react/src/metadata-hooks.tsx +++ b/packages/client-react/src/metadata-hooks.tsx @@ -7,7 +7,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { useClient } from './context'; +import { useClient, useObjectStackLocale } from './context'; /** * Metadata query options @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -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(); @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -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(); @@ -271,6 +277,9 @@ export function useMetadata( options: Omit = {} ): UseMetadataResult { 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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -294,7 +303,7 @@ export function useMetadata( } finally { setIsLoading(false); } - }, [client, fetcher, enabled, onSuccess, onError]); + }, [client, fetcher, locale, enabled, onSuccess, onError]); useEffect(() => { fetchMetadata(); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 18c0a29d62..a839e03fc5 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -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): Record { + const call = fetchMock.mock.calls.at(-1); + return (call?.[1]?.headers ?? {}) as Record; + } + + 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(); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d59c4a48a7..ff2b49822d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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; } /** @@ -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; private discoveryInfo?: DiscoveryResult; private logger: Logger; @@ -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 @@ -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 */ @@ -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', { diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index e783abfde2..506903b013 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -608,6 +608,62 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // 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 // ═══════════════════════════════════════════════════════════════ diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 80a81bae27..ff94ea04e0 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -16,7 +16,7 @@ import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoute import type { IFeedService } from '@objectstack/spec/contracts'; import { parseFilterAST, isFilterAST } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; -import { type FormView } from '@objectstack/spec/ui'; +import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel'; import { @@ -1254,6 +1254,19 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ); } + // Canonical-shape exposure (ADR-0017, "Object has-many View"): a + // `defineView` document is kept in the registry under the bare + // `` key for defensive single-item reads, but it is NOT a + // first-class, independently addressable view — the registrar expands + // it into independent ViewItems (each carrying `viewKind` + `config`). + // Never surface the aggregated `{ list, form, listViews }` container + // through enumeration so every list consumer (Studio metadata list, + // REST `GET /meta/view`, AI schema retriever) sees exactly one + // canonical entry per named view and never the legacy wrapper shape. + if (request.type === 'view' || request.type === 'views') { + items = (items as any[]).filter((it) => !isAggregatedViewContainer(it)); + } + return { type: request.type, items: decorateMetadataItems( @@ -2405,7 +2418,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // Metadata Caching // ========================================== - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { try { // Delegate to getMetaItem so the customization-overlay read order // (sys_metadata → registry → MetadataService) is honoured here too @@ -2417,9 +2430,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { throw new Error(`Metadata item ${request.type}/${request.name} not found`); } - // Calculate ETag (simple hash of the stringified metadata) + // Calculate ETag (simple hash of the stringified metadata). + // + // The ETag MUST vary by locale. The REST layer translates the + // response body *after* this validator check, so an ETag computed + // only from the (untranslated) content would let a language switch + // match the prior `If-None-Match` and return `304 Not Modified` + // carrying a stale-locale body — labels/headers stuck in the old + // language until a hard refresh (issue #1319). Folding the resolved + // locale into the hash gives each locale a distinct validator. const content = JSON.stringify(item); - const hash = simpleHash(content); + const hash = simpleHash(request.locale ? `${request.locale}${content}` : content); const etag = { value: hash, weak: false }; // Check If-None-Match header diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 88efc467cb..cb4fa9c704 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -915,10 +915,13 @@ export class RestServer { * locale yields a match. Falls through unchanged for unsupported types * or missing translations. */ - private async translateMetaItem(req: any, type: string, environmentId: string | undefined, item: any): Promise { + private async translateMetaItem(req: any, type: string, environmentId: string | undefined, item: any, i18nService?: any): Promise { if (!item || typeof item !== 'object') return item; if (type !== 'view' && type !== 'action' && type !== 'object') return item; - const i18n = await this.resolveI18nService(environmentId, req); + // The cached read path resolves the i18n service up-front (to build a + // locale-aware ETag) and passes it here so we don't repeat the + // potentially registry-hitting lookup on every request. + const i18n = i18nService !== undefined ? i18nService : await this.resolveI18nService(environmentId, req); const bundle = this.buildTranslationBundle(i18n); if (!bundle) return item; const locale = this.extractLocale(req, i18n); @@ -1678,10 +1681,19 @@ export class RestServer { ifModifiedSince: req.headers['if-modified-since'] as string, }; + // Resolve the response locale up-front and fold it + // into the cache key. The body is translated below + // (`translateMetaItem`) *after* this validator runs, + // so without a locale-aware ETag a language switch + // would return a stale-locale 304 (issue #1319). + const cacheI18n = await this.resolveI18nService(environmentId, req); + const cacheLocale = this.extractLocale(req, cacheI18n); + const result = await p.getMetaItemCached({ type: req.params.type, name: req.params.name, cacheRequest, + ...(cacheLocale ? { locale: cacheLocale } : {}), ...(environmentId ? { environmentId } : {}), } as any); @@ -1709,7 +1721,7 @@ export class RestServer { } res.header('Vary', 'Accept-Language'); - res.json(await this.translateMetaItem(req, req.params.type, environmentId, result.data)); + res.json(await this.translateMetaItem(req, req.params.type, environmentId, result.data, cacheI18n)); } else { // Non-cached version const packageId = req.query?.package || undefined; diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e512251086..3925718775 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -252,6 +252,11 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), cacheRequest: MetadataCacheRequestSchema.optional().describe('Cache validation parameters'), + locale: z.string().optional().describe( + 'Resolved response locale. Folded into the ETag so a language switch ' + + 'never returns a stale-locale 304 — metadata is translated *after* the ' + + 'cache validator check (issue #1319).', + ), })); /**