From 9e8551a4805306d0637b01ff8b6279234f23b132 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:01:58 +0000 Subject: [PATCH 1/3] fix(app-shell): resolve a page's and a dashboard's inline locale label in the app bar title (objectui#13488) The page and dashboard branches of AppHeader handed a served `label` to the phone bar's title unresolved. `PageSchema.label` and `DashboardSchema.label` are `string | I18nLabel`, so an inline locale map reached JSX and React refused it (error #31), blanking the route. Both now resolve through `metadataText` in the viewer's language, as the report branch does since objectui#13470. Claude-Session: https://claude.ai/code/session_012oUFcAgU5BgTKe6fa7RxD8 Co-authored-by: Claude --- packages/app-shell/src/layout/AppHeader.tsx | 10 +- ...eDashboardTitleInlineLocale-13488.test.tsx | 206 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/app-shell/src/layout/__tests__/AppHeader.pageDashboardTitleInlineLocale-13488.test.tsx diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 6b6e451bf1..83fc2adb4d 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -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 @@ -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') { diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.pageDashboardTitleInlineLocale-13488.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.pageDashboardTitleInlineLocale-13488.test.tsx new file mode 100644 index 0000000000..9564948923 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.pageDashboardTitleInlineLocale-13488.test.tsx @@ -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 & { children?: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const strip = (p: Record | undefined) => { + const { asChild: _a, variant: _v, size: _s, align: _al, sideOffset: _so, onOpenChange: _o, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: Record & { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + ...actual, + Button: ({ children, asChild: _a, variant: _v, size: _s, ...p }: Record & { children?: React.ReactNode }) => ( + + ), + 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 = () => ; + return new Proxy({ __esModule: true } as Record, { + 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>()), + useOffline: () => ({ isOnline: true }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + 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>()), + 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( + + + , + ); +} + +/** 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(['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(['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]); + }); +}); From bf37518d0e9de0c9755cc2d405a1010a1c5a278f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:03:40 +0000 Subject: [PATCH 2/3] fix(components): resolve a page's inline locale label and description in its own heading (objectui#13488) `PageRenderer` drew a page's `label` as its implicit h1 and its `description` under it raw, and handed the raw description to a titled `page:header`. Both are `I18nLabel` in `PageSchema`, so an inline locale map reached JSX and React refused it (error #31). They now resolve through `pickLocalized` in the viewer's language, as `page:header` resolves its own title. One changeset covers this and the app bar fix. Claude-Session: https://claude.ai/code/session_012oUFcAgU5BgTKe6fa7RxD8 Co-authored-by: Claude --- .changeset/13488-page-inline-locale-label.md | 11 +++ .../page-inline-locale-label-13488.test.tsx | 97 +++++++++++++++++++ .../components/src/renderers/layout/page.tsx | 18 +++- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 .changeset/13488-page-inline-locale-label.md create mode 100644 packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx diff --git a/.changeset/13488-page-inline-locale-label.md b/.changeset/13488-page-inline-locale-label.md new file mode 100644 index 0000000000..acddf81f64 --- /dev/null +++ b/.changeset/13488-page-inline-locale-label.md @@ -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. diff --git a/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx b/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx new file mode 100644 index 0000000000..836225b434 --- /dev/null +++ b/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx @@ -0,0 +1,97 @@ +/** + * 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'; +// 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, lang: Lang) => (lang === 'zh-CN' ? map['zh-CN'] : map.en); + +/** An `app` page: not a record page, so it draws its own heading. */ +function appPage(fields: Record, regions: unknown[] = []) { + return { type: 'app', pageType: 'app', name: 'welcome', regions, ...fields } as any; +} + +/** 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: unknown, lang: Lang) { + return render( + + + + + , + ); +} + +describe.each(['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); + }); +}); diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index a8c2e83889..3d142e7f8f 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -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'; @@ -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
, and nothing else. // @@ -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. @@ -945,13 +953,13 @@ export const PageRenderer: React.FC<{ )} {drawDescriptionHere && ( -

{schema.description}

+

{pageDescription}

)}
)} {/* Page body — type-specific layout */} - + {layoutElement} From 2c82e0385dff6b4bd590d98afb16fd426156aac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:17:03 +0000 Subject: [PATCH 3/3] test(components): type the objectui#13488 page fixture as a DeclaredNode The inline-locale page fixture arrives as data, so it is cast once to the `SchemaRenderer` prop type instead of through `any`. Claude-Session: https://claude.ai/code/session_012oUFcAgU5BgTKe6fa7RxD8 Co-authored-by: Claude --- .../page-inline-locale-label-13488.test.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx b/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx index 836225b434..80d7c1b503 100644 --- a/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx +++ b/packages/components/src/__tests__/page-inline-locale-label-13488.test.tsx @@ -33,6 +33,7 @@ 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'; @@ -50,19 +51,23 @@ const PLAIN = { label: 'Welcome', description: 'Shown to suppliers first.' }; type Lang = 'en' | 'zh-CN'; const pick = (map: Record, lang: Lang) => (lang === 'zh-CN' ? map['zh-CN'] : map.en); -/** An `app` page: not a record page, so it draws its own heading. */ -function appPage(fields: Record, regions: unknown[] = []) { - return { type: 'app', pageType: 'app', name: 'welcome', regions, ...fields } as any; +/** + * 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, 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: unknown, lang: Lang) { +function renderPage(schema: DeclaredNode, lang: Lang) { return render( - + , );