- {typeof page.label === 'string' ? page.label : page.name}
+ {pageLabel ?? page.name}
- {typeof page.description === 'string' && page.description && (
-
{page.description}
+ {pageDescription && (
+
{pageDescription}
)}
{buttonActions.length > 0 && (
@@ -672,7 +679,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
{ if (!o) closeRecordDrawer(); }}
- title={typeof page.label === 'string' ? page.label : (cfg.source || 'Record')}
+ title={pageLabel ?? (cfg.source || 'Record')}
>
{(record: any) => (
: undefined,
};
});
- }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews]);
+ }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]);
useMobileViewSwitcherRegistration({
views: mobileViewSwitcherItems,
activeViewId: activeViewId ?? '',
@@ -2878,7 +2878,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const originState = {
from: {
pathname: location.pathname + (location.search || ''),
- label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText),
+ label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
},
};
if (viewId) {
@@ -2887,7 +2887,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState });
}
},
- [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews]
+ [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language]
);
/**
* The list surface's half of the record-link mechanism (objectui#4490):
@@ -3015,7 +3015,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const isSystem = !saved;
return {
id: view.id,
- label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText),
+ label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
type: view.type,
hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
@@ -3881,7 +3881,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const originState = {
from: {
pathname: location.pathname + (location.search || ''),
- label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText),
+ label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
},
};
if (viewId) {
@@ -3891,7 +3891,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
}
}
},
- }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews]);
+ }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]);
// The header's primary action, drawn the same in both of the header's
// faces (below). [#5153] `objectCanCreate && createVisible` — the
diff --git a/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx b/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx
new file mode 100644
index 00000000000..d7d08e3643c
--- /dev/null
+++ b/ui/packages/app-shell/src/views/__tests__/InterfaceListPage.inlineLabel-25901.test.tsx
@@ -0,0 +1,127 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #25901 — an interface list page whose `label` / `description` is an inline
+ * locale map draws them in the viewer's language.
+ *
+ * `@objectstack/spec` types both as `I18nLabel`. The page read
+ * `typeof page.label === 'string' ? page.label : page.name` for its heading and
+ * its record drawer's title, and drew `page.description` only when it was a
+ * string: a map label showed the page's machine name, a map description was
+ * dropped. Both now go through `metadataText`, the helper the header and the
+ * report page resolve the same values with; the machine name is the heading
+ * only when no label resolves.
+ *
+ * Harness: `InterfaceListPage.packageScope-12980`'s — the real
+ * `MetadataProvider` over a client double, in an app with no package — under a
+ * real `I18nProvider` set to the viewer's language.
+ *
+ * Directions, written before the run: the map cells RED before the change (the
+ * machine name, no description), GREEN after; the string control GREEN on
+ * both sides.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import React from 'react';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { AdapterCtx, useMetadata } from '@object-ui/react';
+import { I18nProvider } from '@object-ui/i18n';
+
+import { MetadataProvider } from '../../providers/MetadataProvider';
+import { InterfaceListPage } from '../InterfaceListPage';
+import { readPageObjectScope } from '../../console/appObjectScope';
+
+const OBJECTS = [{ name: 'crm_account', label: 'Accounts', fields: { name: { type: 'text', label: 'Name' } } }];
+const APPS = [{ name: 'crm', label: 'CRM' }];
+const MAP_PAGE = {
+ name: 'accounts_page',
+ label: { en: 'Key accounts', 'zh-CN': '重点客户' },
+ description: { en: 'Accounts we review weekly', 'zh-CN': '每周复盘的客户' },
+ type: 'app',
+ interfaceConfig: { source: 'crm_account' },
+};
+const STRING_PAGE = { ...MAP_PAGE, label: 'Key accounts', description: 'Accounts we review weekly' };
+
+function adapter(page: unknown) {
+ return {
+ clearCache: vi.fn(),
+ getObjectSchema(this: { getClient(): any }, name: string) {
+ return this.getClient()
+ .meta.getItem('object', name)
+ .then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res));
+ },
+ getClient: () => ({
+ meta: {
+ getItems: (type: string) => {
+ const items = type === 'object' ? OBJECTS : type === 'app' ? APPS : type === 'page' ? [page] : [];
+ return Promise.resolve({ type, items: structuredClone(items) });
+ },
+ getItem: (type: string, name: string) => {
+ const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name);
+ return item ? Promise.resolve({ type, name, item: structuredClone(item) }) : Promise.reject(new Error('404'));
+ },
+ },
+ }),
+ } as unknown as Parameters[0]['adapter'];
+}
+
+function PageRoute({ page }: { page: any }) {
+ const metadata = useMetadata();
+ const app = (metadata.apps as any[]).find((a) => a?.name === 'crm');
+ if (!app) return null;
+ const { ready } = readPageObjectScope(metadata.readObjectScope!, app, page);
+ if (!ready) return ;
+ return ;
+}
+
+async function boot(page: any, language: 'en' | 'zh-CN') {
+ const ds = { find: vi.fn(async () => ({ data: [], total: 0 })), getObjectSchema: vi.fn(async () => OBJECTS[0]) };
+ render(
+
+
+
+
+
+ } />
+
+
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('interface-list-page')).toBeInTheDocument());
+}
+
+beforeEach(() => {
+ sessionStorage.clear();
+ localStorage.clear();
+});
+afterEach(cleanup);
+
+describe('InterfaceListPage — inline locale-map label and description (#25901)', () => {
+ it.each([
+ ['en', 'Key accounts', 'Accounts we review weekly'],
+ ['zh-CN', '重点客户', '每周复盘的客户'],
+ ] as const)('%s: the heading and description are the map\'s text, never the machine name', async (language, label, description) => {
+ await boot(MAP_PAGE, language);
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(label);
+ expect(screen.getByText(description)).toBeInTheDocument();
+ expect(screen.queryByText(MAP_PAGE.name)).not.toBeInTheDocument();
+ });
+
+ it('a map that resolves to no text falls back to the machine name', async () => {
+ await boot({ ...MAP_PAGE, label: {}, description: {} }, 'en');
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(MAP_PAGE.name);
+ });
+
+ it('control — a string label and description are drawn as given', async () => {
+ await boot(STRING_PAGE, 'zh-CN');
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Key accounts');
+ expect(screen.getByText('Accounts we review weekly')).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts b/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts
new file mode 100644
index 00000000000..ffe3d3b528b
--- /dev/null
+++ b/ui/packages/app-shell/src/views/viewTabLabel.inlineLabel-25901.test.ts
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #25901 — `viewTabLabel` resolves a view's `I18nLabel` in the viewer's
+ * language before either branch, so no caller receives an inline locale map.
+ *
+ * The header, the object page's tab bar, the mobile view switcher and the
+ * record page's way back all draw this function's answer as text. Before the
+ * change a map label came back as the map itself — on the served branch drawn
+ * as given, on the other offered to the bundle as the fallback, which the
+ * bundle returns untouched on a miss — and React threw on it
+ * (`AppHeader.inlineViewLabel-25901` pins the header, where the throw took the
+ * whole console down).
+ *
+ * The served/derived split is unchanged: a served view's resolved text is drawn
+ * as given, any other view's goes through `viewLabel` with the resolved text as
+ * its fallback, so a catalog entry still wins there.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { viewTabLabel, type ViewLabelLookup } from './viewTabLabel';
+
+const OBJECT = 'recheck_gate';
+const UNLABELLED = 'QA Gates';
+const LABEL = { en: 'My gates', 'zh-CN': '我的关卡' };
+
+/** The `/meta/object` read served `served_map`; `derived_map` it did not. */
+const SERVED = Object.freeze({
+ views: [] as readonly unknown[],
+ objects: [{ name: OBJECT, listViews: { served_map: { label: LABEL } } }] as readonly unknown[],
+});
+
+/** A bundle with no entry for any view: every lookup answers its fallback. */
+const missingBundle: ViewLabelLookup = (_object, _view, fallback) => fallback;
+
+describe('viewTabLabel resolves an inline locale-map label (#25901)', () => {
+ it.each([
+ ['en', 'My gates'],
+ ['zh-CN', '我的关卡'],
+ ['zh', '我的关卡'],
+ ])('a served view, %s', (language, expected) => {
+ const lookup = vi.fn(missingBundle);
+ const text = viewTabLabel({ id: 'served_map', name: 'served_map', label: LABEL }, OBJECT, SERVED, lookup, UNLABELLED, language);
+
+ expect(text).toBe(expected);
+ // Drawn as given: the served branch never asks the bundle.
+ expect(lookup).not.toHaveBeenCalled();
+ });
+
+ it('a view no server read served hands the bundle the RESOLVED text as its fallback', () => {
+ const lookup = vi.fn(missingBundle);
+ const text = viewTabLabel({ id: 'derived_map', name: 'derived_map', label: LABEL }, OBJECT, SERVED, lookup, UNLABELLED, 'zh-CN');
+
+ expect(text).toBe('我的关卡');
+ expect(lookup).toHaveBeenCalledWith(OBJECT, 'derived_map', '我的关卡');
+ });
+
+ it('a catalog entry still wins for a view no server read served', () => {
+ const catalog: ViewLabelLookup = () => 'Catalog gates';
+ expect(viewTabLabel({ id: 'derived_map', name: 'derived_map', label: LABEL }, OBJECT, SERVED, catalog, UNLABELLED, 'zh-CN')).toBe('Catalog gates');
+ });
+
+ it('a map that resolves to no text is drawn as the unlabelled text', () => {
+ expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: {} }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED);
+ expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: { en: ' ' } }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED);
+ });
+
+ it('control — a string label is drawn as before', () => {
+ expect(viewTabLabel({ id: 'served_map', name: 'served_map', label: 'Open gates' }, OBJECT, SERVED, missingBundle, UNLABELLED, 'zh-CN')).toBe('Open gates');
+ expect(viewTabLabel({ id: 'served_map', name: 'served_map' }, OBJECT, SERVED, missingBundle, UNLABELLED, 'en')).toBe(UNLABELLED);
+ });
+});
diff --git a/ui/packages/app-shell/src/views/viewTabLabel.ts b/ui/packages/app-shell/src/views/viewTabLabel.ts
index 3513864108c..89eb2ede80d 100644
--- a/ui/packages/app-shell/src/views/viewTabLabel.ts
+++ b/ui/packages/app-shell/src/views/viewTabLabel.ts
@@ -7,6 +7,7 @@
*/
import { isServedView, type useServedViewItems } from '../hooks/useServedViewItems.js';
+import { metadataText } from '../hooks/useNavTargetLabel.js';
/** `useObjectLabel().viewLabel` — the client bundle's view-label lookup. */
export type ViewLabelLookup = (objectName: string, viewName: string, fallback: string) => string;
@@ -34,14 +35,23 @@ export type ViewLabelLookup = (objectName: string, viewName: string, fallback: s
* qualified name, which is what a label-less view's tab drew before
* (objectui#12774), nor a string humanized from it, which is what the header
* drew before (objectui#12805).
+ *
+ * The `label` is an `I18nLabel` — a string or an inline locale map — and is
+ * resolved in the viewer's `language` before either branch reads it (#25901).
+ * The server overlays only catalog text, so a map with no catalog entry is
+ * served as authored; handed on raw, it reached every caller as an object, and
+ * the app header — mounted outside the route's error boundary — threw on it and
+ * took the whole console down. A map that resolves to no text counts as no
+ * `label`, so `unlabelled` is drawn only when nothing resolves.
*/
export function viewTabLabel(
- view: { id?: string; name?: string; label?: string },
+ view: { id?: string; name?: string; label?: unknown },
objectName: string,
servedViews: ReturnType,
viewLabel: ViewLabelLookup,
unlabelled: string,
+ language: string | undefined,
): string {
- const text = view.label || unlabelled;
+ const text = metadataText(view.label, language) ?? unlabelled;
return isServedView(servedViews, objectName, view.id) ? text : viewLabel(objectName, view.name || view.id || '', text);
}