Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
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
11 changes: 11 additions & 0 deletions .changeset/13488-page-inline-locale-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/components': patch
---

Clause-②: no
Metadata-protocol: none

A page whose `label` or `description` is an inline locale map (`{ en, 'zh-CN' }`) now opens, and shows both in the viewer's language (objectui#13488). A dashboard whose `label` is one no longer blanks its route through the app bar.

`@objectstack/spec` types a page's `label` and `description`, and a dashboard's `label`, as `I18nLabel`, so the map is valid metadata and is served unchanged. The page drew it straight into its own heading, the line under it, and the description a titled `page:header` draws, and the app bar drew a page's or a dashboard's label as the phone bar's title: React refused the object (error #31) and the route was blank for every viewer. Each of these now resolves the map to the viewer's language. A plain-string label or description draws exactly as before.
10 changes: 7 additions & 3 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,8 +682,10 @@ export function AppHeader({
// translated (a published edit kept over the packaged catalog), so it
// is drawn as given — the page header draws it the same way. Only the
// humanized slug, which no server translated, goes through the bundle
// (objectui#11295).
routeTitle = dashboardDef?.label || dashboardLabel({ name: dashboardName, label: humanizeSlug(dashboardName) });
// (objectui#11295). The label may be an inline locale map; drawn raw,
// it blanked the route (objectui#13488).
routeTitle = metadataText(dashboardDef?.label, language)
?? dashboardLabel({ name: dashboardName, label: humanizeSlug(dashboardName) });
}
} else if (routeType === 'page') {
// No crumb at all since objectui#12526 (no 「Pages」 type crumb since
Expand All @@ -695,7 +697,9 @@ export function AppHeader({
if (pathParts[3]) {
const pageName = pathParts[3];
const pageDef = preferLocal(metadata.pages as any[], pageName, (currentApp as any)?._packageId);
const fallback = pageDef?.label || humanizeSlug(pageName);
// A page's `label` may be an inline locale map; drawn raw, the phone
// bar's title blanked the page (objectui#13488).
const fallback = metadataText(pageDef?.label, language) ?? humanizeSlug(pageName);
routeTitle = pageLabel({ name: pageName, label: fallback });
}
} else if (routeType === 'report') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#13488 — the phone bar's title on a page route, and on a dashboard
* route, names a page or dashboard whose `label` is the spec's inline locale
* map (`I18nLabel`, `{ 'zh-CN', en }`) in the viewer's language.
*
* `PageSchema.label` and `DashboardSchema.label` are `string | I18nLabel`. The
* page branch handed the served label to `pageLabel` as its fallback, which
* returns an unmatched fallback untouched, and the dashboard branch drew the
* served label as given: either way the map reached JSX as the phone bar's
* title and React refused it (error #31), so the bar, and the route under it,
* went blank. It is the page-route twin of the report crumb
* (`AppHeader.reportCrumbInlineLocale-13470`).
*
* Harness: `AppHeader.reportCrumbInlineLocale-13470`'s, with `useObjectLabel`
* REAL: the bundle carries no `pages` or `dashboards` key, so the title is the
* resolved served label. The zh and en legs read different strings out of one
* map; `PLAIN_STRING_CONTROL` is a page and a dashboard labelled with a plain
* string, drawn unchanged.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import React from 'react';

const route = vi.hoisted(() => ({ pathname: '/apps/srm/page/welcome' }));

vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: route.pathname, search: '', hash: '', state: null, key: 't' }),
useParams: () => ({ appName: 'srm' }),
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams(), vi.fn()] as const,
Link: ({ children, to, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
<a href={String(to)} {...p}>{children}</a>
),
}));

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const strip = (p: Record<string, unknown> | undefined) => {
const { asChild: _a, variant: _v, size: _s, align: _al, sideOffset: _so, onOpenChange: _o, ...rest } = p ?? {};
return rest;
};
const Pass = ({ children, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
<div {...strip(p)}>{children}</div>
);
return {
...actual,
Button: ({ children, asChild: _a, variant: _v, size: _s, ...p }: Record<string, unknown> & { children?: React.ReactNode }) => (
<button type="button" {...p}>{children}</button>
),
DropdownMenu: Pass,
DropdownMenuTrigger: Pass,
DropdownMenuContent: () => null,
DropdownMenuItem: () => null,
DropdownMenuLabel: () => null,
DropdownMenuSeparator: () => null,
DropdownMenuGroup: Pass,
Avatar: Pass,
AvatarImage: () => null,
AvatarFallback: Pass,
Popover: Pass,
PopoverTrigger: Pass,
PopoverContent: () => null,
cn: (...c: unknown[]) => c.filter(Boolean).join(' '),
};
});

vi.mock('lucide-react', () => {
const Icon = () => <span />;
return new Proxy({ __esModule: true } as Record<string | symbol, unknown>, {
get: (target, prop) => {
if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop];
return Icon;
},
has: (_target, prop) => prop !== 'then',
});
});

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useOffline: () => ({ isOnline: true }),
}));
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
PresenceAvatars: () => null,
useTenantPresence: () => [],
}));
vi.mock('../ModeToggle', () => ({ ModeToggle: () => null }));
vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null }));
vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null }));
vi.mock('../AppSwitcher', () => ({ AppSwitcher: () => null }));
vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null }));
vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null }));
vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null }));

// ONE module-scope fetch double, never torn down: the header's pollers fire and
// forget, and may read after a test body returns (see the sibling suites).
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [], items: [], agents: [], requests: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);

/** The page's inline map, and what each viewer must read out of it. */
const PAGE_LABEL_MAP = { 'zh-CN': '供应商门户首页', en: 'Supplier Portal Home' };
/** The dashboard's inline map. */
const DASHBOARD_LABEL_MAP = { 'zh-CN': '供应商绩效看板', en: 'Supplier Performance Board' };
const PLAIN_PAGE_LABEL = 'Welcome';
const PLAIN_DASHBOARD_LABEL = 'Ops Board';

vi.mock('../../providers/MetadataProvider', () => ({
useMetadata: () => ({
apps: [{ name: 'srm', label: 'SRM' }],
dashboards: [
{ name: 'srm_performance', label: DASHBOARD_LABEL_MAP },
{ name: 'ops_board', label: PLAIN_DASHBOARD_LABEL },
],
pages: [
{ name: 'welcome', type: 'app', label: PAGE_LABEL_MAP },
{ name: 'welcome_plain', type: 'app', label: PLAIN_PAGE_LABEL },
],
reports: [],
}),
}));
vi.mock('../../context/NavigationContext.js', () => ({
useNavigationContext: () => ({ currentAppName: 'srm', recordTitle: undefined }),
}));
vi.mock('@object-ui/auth', async (importOriginal) => {
// ONE stable identity — a fresh closure per render re-arms every
// `[getAuthConfig]` effect forever (see the sibling suites).
const getAuthConfig = () => Promise.resolve({ features: { multiOrgEnabled: false } });
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Zhang San', email: 'zs@example.com' },
signOut: vi.fn(),
isAuthEnabled: true,
organizations: [],
activeOrganization: null,
isOrganizationsLoading: false,
switchOrganization: vi.fn(),
getAuthConfig,
}),
getUserInitials: () => 'ZS',
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
};
});
const fakeAdapter = { find: () => Promise.resolve({ data: [] }), getClient: () => undefined };
vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter }));

import { I18nProvider } from '@object-ui/i18n';
import { AppHeader } from '../AppHeader';

afterEach(cleanup);

type Lang = 'en' | 'zh-CN';

function renderAt(pathname: string, lang: Lang) {
route.pathname = pathname;
return render(
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }} persistLanguage={false}>
<AppHeader variant="app" appName="SRM" activeAppName="srm" objects={[]} />
</I18nProvider>,
);
}

/** The phone bar's compact title: the bar's one `sm:hidden` title span. */
function phoneTitles(): string[] {
return [...document.querySelectorAll('span.sm\\:hidden.truncate')].map((s) => s.textContent?.trim() ?? '');
}

describe('a page route\'s phone title resolves an inline locale label (objectui#13488)', () => {
it.each<[Lang, string]>([
['en', PAGE_LABEL_MAP.en],
['zh-CN', PAGE_LABEL_MAP['zh-CN']],
])('PAGE_LABEL_MAP_RESOLVES — %s: the title is the viewer\'s label', (lang, expected) => {
renderAt('/apps/srm/page/welcome', lang);
expect(phoneTitles()).toEqual([expected]);
});

it.each<Lang>(['en', 'zh-CN'])('PLAIN_STRING_CONTROL — %s: a plain-string page label draws unchanged', (lang) => {
renderAt('/apps/srm/page/welcome_plain', lang);
expect(phoneTitles()).toEqual([PLAIN_PAGE_LABEL]);
});
});

describe('a dashboard route\'s phone title resolves an inline locale label (objectui#13488)', () => {
it.each<[Lang, string]>([
['en', DASHBOARD_LABEL_MAP.en],
['zh-CN', DASHBOARD_LABEL_MAP['zh-CN']],
])('DASHBOARD_LABEL_MAP_RESOLVES — %s: the title is the viewer\'s label', (lang, expected) => {
renderAt('/apps/srm/dashboard/srm_performance', lang);
expect(phoneTitles()).toEqual([expected]);
});

it.each<Lang>(['en', 'zh-CN'])('PLAIN_STRING_CONTROL — %s: a plain-string dashboard label draws unchanged', (lang) => {
renderAt('/apps/srm/dashboard/ops_board', lang);
expect(phoneTitles()).toEqual([PLAIN_DASHBOARD_LABEL]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* objectui#13488 — a page whose `label` and `description` are the spec's
* inline locale map (`I18nLabel`, `{ 'zh-CN', en }`) renders, in the viewer's
* language.
*
* ## The defect
*
* `PageSchema` in `@objectstack/spec` types both keys as `I18nLabelSchema`,
* so the map is valid metadata and the `/meta` read serves it unchanged.
* `PageRenderer` drew `label` as the page's implicit heading and `description`
* under it as given, and handed the raw description to a titled `page:header`
* through `PageDescriptionContext`: React refused the object as a child (error
* #31) and the page was blank for every viewer.
*
* ## Discrimination
*
* Each map leg goes red on the raw read it pins: the heading leg on the h1,
* the description leg on the paragraph under it, the header leg on the
* description the header draws. The zh-CN and en legs read different strings
* out of the same map, which a resolver that took the first entry, or always
* `en`, cannot both pass. `PLAIN_STRING_CONTROL` is the same pages written with
* plain strings, asserted unchanged in both languages.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ActionProvider, SchemaRenderer } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
import type { DeclaredNode } from '@object-ui/types';
// Module scope, not a hook: registers PageRenderer (`app`/`home`/`record`/…)
// and `page:header` (AGENTS.md §测试纪律).
import '../renderers';

afterEach(cleanup);

/** Each inline map, and what each viewer must read out of it. */
const LABEL_MAP = { 'zh-CN': '供应商门户首页', en: 'Supplier Portal Home' };
const DESCRIPTION_MAP = { 'zh-CN': '待办的订单与对账一览。', en: 'Open orders and reconciliations at a glance.' };
const HEADER_TITLE = 'Supplier portal';

/** PLAIN_STRING_CONTROL — the same page written with plain strings. */
const PLAIN = { label: 'Welcome', description: 'Shown to suppliers first.' };

type Lang = 'en' | 'zh-CN';
const pick = (map: Record<string, string>, lang: Lang) => (lang === 'zh-CN' ? map['zh-CN'] : map.en);

/**
* An `app` page: not a record page, so it draws its own heading. Its label and
* description arrive as data (a served document), so the literal is cast once
* here rather than typed against the authoring face.
*/
function appPage(fields: Record<string, unknown>, regions: unknown[] = []): DeclaredNode {
return { type: 'app', pageType: 'app', name: 'welcome', regions, ...fields } as unknown as DeclaredNode;
}

/** A header region with a titled `page:header`: the header owns the h1 and draws the description. */
const TITLED_HEADER_REGION = [{ name: 'header', components: [{ type: 'page:header', properties: { title: HEADER_TITLE } }] }];

function renderPage(schema: DeclaredNode, lang: Lang) {
return render(
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }} persistLanguage={false}>
<ActionProvider>
<SchemaRenderer schema={schema} />
</ActionProvider>
</I18nProvider>,
);
}

describe.each<Lang>(['en', 'zh-CN'])('objectui#13488 — a page with inline locale maps, viewer in %s', (lang) => {
it('HEADING_MAP_RESOLVES — the page\'s heading is the viewer\'s label', () => {
renderPage(appPage({ label: LABEL_MAP }), lang);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(pick(LABEL_MAP, lang));
});

it('DESCRIPTION_MAP_RESOLVES — the line under the heading is the viewer\'s description', () => {
renderPage(appPage({ label: PLAIN.label, description: DESCRIPTION_MAP }), lang);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(PLAIN.label);
expect(screen.getByText(pick(DESCRIPTION_MAP, lang))).toBeInTheDocument();
});

it('HEADER_DESCRIPTION_MAP_RESOLVES — a titled `page:header` draws the viewer\'s description', () => {
const { container } = renderPage(appPage({ label: PLAIN.label, description: DESCRIPTION_MAP }, TITLED_HEADER_REGION), lang);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(HEADER_TITLE);
expect(container.querySelector('[data-page-description]')).toHaveTextContent(pick(DESCRIPTION_MAP, lang));
});

it('PLAIN_STRING_CONTROL — a plain-string label and description draw unchanged', () => {
renderPage(appPage(PLAIN), lang);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(PLAIN.label);
expect(screen.getByText(PLAIN.description)).toBeInTheDocument();
cleanup();
const { container } = renderPage(appPage(PLAIN, TITLED_HEADER_REGION), lang);
expect(container.querySelector('[data-page-description]')).toHaveTextContent(PLAIN.description);
});
});
18 changes: 13 additions & 5 deletions packages/components/src/renderers/layout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
resolveInlineAriaProps,
} from '@object-ui/react';
import { ComponentRegistry, toDomProps } from '@object-ui/core';
import { useDisplayLocale } from '@object-ui/i18n';
import { pickLocalized, useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser';
import { PAGE_TEMPLATE_SLOTS, type PageTemplate } from '@objectstack/spec/ui';
import { ReactKindPage } from './react-page';
Expand Down Expand Up @@ -744,7 +744,15 @@ export const PageRenderer: React.FC<{
// Spec PageSchema declares `label` (required); `title` is the objectui
// spelling. Dual-read so a spec-authored page still renders its header
// (framework#1878 §3 naming-drift recheck).
const pageTitle = schema.title ?? (schema as any).label;
//
// `label` and `description` are the spec's `string | I18nLabel`, so an
// inline locale map (`{ en, 'zh-CN' }`) is valid metadata. Drawn raw, React
// refused the object and the page went blank (objectui#13488). Both resolve
// to the viewer's language through `pickLocalized`, as `page:header`
// resolves its own title; a plain string passes through unchanged.
const { language } = useObjectTranslation();
const pageTitle = pickLocalized(schema.title ?? (schema as any).label, language);
const pageDescription = pickLocalized(schema.description, language);

// What may become an attribute on the wrapper <div>, and nothing else.
//
Expand Down Expand Up @@ -905,7 +913,7 @@ export const PageRenderer: React.FC<{
const showPageTitle = !!pageTitle && !headerOwnsTitle;
// The description is the page's own prose, not a duplicate of the header's
// `subtitle`, so delegating the heading does not delete it.
const showPageDescription = !!schema.description && pageType !== 'record';
const showPageDescription = !!pageDescription && pageType !== 'record';
// When the header owns the title, it draws the description too, under the
// title (objectui#12624). Drawn here it sat above the regions, so above the
// title the header draws inside them.
Expand Down Expand Up @@ -945,13 +953,13 @@ export const PageRenderer: React.FC<{
</h1>
)}
{drawDescriptionHere && (
<p className="text-muted-foreground">{schema.description}</p>
<p className="text-muted-foreground">{pageDescription}</p>
)}
</div>
)}

{/* Page body — type-specific layout */}
<PageDescriptionContext.Provider value={headerDrawsDescription ? schema.description : null}>
<PageDescriptionContext.Provider value={headerDrawsDescription ? pageDescription : null}>
<PageWidthProvider store={pageWidthStore}>{layoutElement}</PageWidthProvider>
</PageDescriptionContext.Provider>
</div>
Expand Down
Loading