diff --git a/.changeset/10447-one-title-interpolator.md b/.changeset/10447-one-title-interpolator.md
new file mode 100644
index 0000000000..13bedc303c
--- /dev/null
+++ b/.changeset/10447-one-title-interpolator.md
@@ -0,0 +1,19 @@
+---
+'@object-ui/core': patch
+'@object-ui/components': patch
+---
+
+One `titleFormat` interpolator for the record title (objectui#10447, objectui#10446).
+
+`@object-ui/core`: `formatTitleTemplate` now judges a placeholder empty the way `recordDisplayValueAt` does: trim, then empty. A whitespace-only value used to count as resolved, so `{contract_no} - {name}` with a blank `name` rendered `HT-2026-003 -` in `formatTitleTemplate` and in `getRecordDisplayName`'s `titleFormat` rung, and so on every surface backed by that resolver. It now renders `HT-2026-003`. Both functions run one shared implementation of the rule, so a resolved placeholder renders the same trimmed display string `recordDisplayValueAt` returns. The exported signatures are unchanged.
+
+`@object-ui/components`: the record page H1 (`page:header`) renders its `titleFormat` rung through core's `formatTitleTemplate`, the function `getRecordDisplayName` and `record:details`' H1 dedupe already use, instead of its own interpolation and separator cleanup. Visible changes on that rung:
+
+- An expanded lookup token renders its display name: `{account} - {deal_no}` reads `Acme - Q3-042` where the H1 used to drop the lookup and read `Q3-042`, so `record:details` no longer prints the row it wrongly treated as distinct from the heading.
+- A comma left by an empty placeholder is stripped: `{deal_no}, {code}` with no `code` reads `Q3-042`, not `Q3-042,`.
+- A template none of whose placeholders resolves no longer shows its literal text (`Session — {user_id}` with no user used to read `Session`); the H1 walks on to the next rung, as `getRecordDisplayName` does.
+- A select token still reads as its translated option label: the label is applied to a copy of the record before core renders it.
+
+The rungs' order is unchanged. `page:header`'s own `title` and `subtitle` templates keep their existing interpolation.
+
+This supersedes how two earlier changesets, still pending in the same release, describe the `titleFormat` rung; each now carries a dated note naming this card. objectui#9436 (the declared pointer outranks `titleFormat` in `page:header`) said the template kept the header's own interpolation. objectui#9174 (`interpolate()`'s no-token fast path) listed the record-title `titleFormat` among `interpolate()`'s callers. Both describe the header as it was at their change; from this release the rung renders through `formatTitleTemplate`.
diff --git a/.changeset/9174-interpolate-fastpath-trim.md b/.changeset/9174-interpolate-fastpath-trim.md
index b99bad2fbc..919780536e 100644
--- a/.changeset/9174-interpolate-fastpath-trim.md
+++ b/.changeset/9174-interpolate-fastpath-trim.md
@@ -4,7 +4,7 @@
Fix `interpolate()`'s no-token fast path skipping its own trim (objectui#9174).
-`page:header`'s `title`/`subtitle` (and the record-title `titleFormat`) all go through
+At this change `page:header`'s `title`/`subtitle` (and the record-title `titleFormat`) all went through
`interpolate()` in `packages/components/src/renderers/layout/containers.tsx`. When the
template contained a `{token}` the function collapsed and trimmed whitespace before
returning; when it contained no `{` at all it returned the raw string untouched. A
@@ -15,6 +15,14 @@ its own heading to the authored header, already trimmed and correctly read "no t
`PageRenderer` also drew its own implicit heading — two `
` elements on one document,
the broken outline objectui#3434 closed, arriving through a different door.
+⚠️ **Dated note, 2026-09-25 — the record title's titleFormat rung now renders through core's formatTitleTemplate — objectui#10447.**
+Later in this same release the record-title `titleFormat` stopped going through
+`interpolate()`: it renders through `@object-ui/core`'s `formatTitleTemplate`, and a
+select token still reads as its translated option label, applied by
+`withOptionLabels` to a copy of the record before core renders it. `title` and
+`subtitle` still go through `interpolate()`, so the fix below holds for both of them
+unchanged.
+
The fix removes the divergence rather than patching around it: the no-`{` fast path
still skips the token-substitution `replace()` callback (its actual performance
saving), but every template — token or not — now falls through to the same, single
diff --git a/.changeset/9436-page-header-declared-pointer-order.md b/.changeset/9436-page-header-declared-pointer-order.md
index 2531384f6c..c141442686 100644
--- a/.changeset/9436-page-header-declared-pointer-order.md
+++ b/.changeset/9436-page-header-declared-pointer-order.md
@@ -21,14 +21,25 @@ The header now resolves its title in this order:
2. the declared pointer: `nameField`, then its deprecated `displayNameField`
alias. This rung is new, and it only applies when the pointer holds a value
on the record;
-3. `titleFormat`, rendered with the header's own interpolation, including
- i18n option labels, exactly as before;
+3. `titleFormat`, rendered at this change with the header's own
+ interpolation, including i18n option labels, exactly as before;
4. the type-aware derivation, then the record-key rung, then
`${objectLabel} ${id}`, all unchanged.
A pointer that is blank on a record still falls through to the template.
-Objects that declare a pointer and no `titleFormat`, and objects that declare a
-`titleFormat` and no pointer, render exactly as before.
+At this change, objects that declare a pointer and no `titleFormat`, and
+objects that declare a `titleFormat` and no pointer, render exactly as before.
+
+⚠️ **Dated note, 2026-09-25 — the record title's titleFormat rung now renders through core's formatTitleTemplate — objectui#10447.**
+Later in this same release rung 3 stopped using the header's own
+interpolation: the template goes to `@object-ui/core`'s `formatTitleTemplate`,
+the renderer `getRecordDisplayName`, `DetailView` and the `record:details` H1
+dedupe already use. A select token still reads as its translated option label,
+applied by `withOptionLabels` to a copy of the record before core renders it.
+The order above is unchanged. What the rung now renders differently for a
+`titleFormat`-only object (an expanded lookup token, a whitespace-only value,
+a template with no resolved placeholder) is stated in the objectui#10447 entry;
+the rest of this entry is kept as the reading of this change.
**Upgrade effect.** Any object that declares BOTH a `nameField` (or
`displayNameField`) and a `titleFormat` whose rendering differs from that
diff --git a/packages/components/src/__tests__/cleanup-title-separators.test.ts b/packages/components/src/__tests__/cleanup-title-separators.test.ts
deleted file mode 100644
index 13ec9d1a59..0000000000
--- a/packages/components/src/__tests__/cleanup-title-separators.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * 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.
- */
-
-import { describe, it, expect } from 'vitest';
-import { cleanupTitleSeparators } from '../renderers/layout/containers';
-
-describe('cleanupTitleSeparators', () => {
- it('returns empty input unchanged', () => {
- expect(cleanupTitleSeparators('')).toBe('');
- });
-
- it('preserves a clean title', () => {
- expect(cleanupTitleSeparators('CTR-0001 - Acme Contract')).toBe('CTR-0001 - Acme Contract');
- });
-
- it('strips a trailing dash from a missing tail interpolation', () => {
- expect(cleanupTitleSeparators('CTR-0001 -')).toBe('CTR-0001');
- expect(cleanupTitleSeparators('CTR-0001 - ')).toBe('CTR-0001');
- });
-
- it('strips a trailing middle-dot or colon connector', () => {
- expect(cleanupTitleSeparators('Acme ·')).toBe('Acme');
- expect(cleanupTitleSeparators('Status :')).toBe('Status');
- });
-
- it('strips a leading dash from a missing head interpolation', () => {
- expect(cleanupTitleSeparators('- Acme')).toBe('Acme');
- expect(cleanupTitleSeparators(' · Acme')).toBe('Acme');
- });
-
- it('collapses double separators left by an empty middle field', () => {
- expect(cleanupTitleSeparators('A - - B')).toBe('A - B');
- expect(cleanupTitleSeparators('A · · B')).toBe('A · B');
- });
-
- it('handles em-dash and en-dash connectors', () => {
- expect(cleanupTitleSeparators('CTR-0001 —')).toBe('CTR-0001');
- expect(cleanupTitleSeparators('CTR-0001 –')).toBe('CTR-0001');
- });
-
- it('does not strip dashes inside an identifier', () => {
- expect(cleanupTitleSeparators('CTR-0001')).toBe('CTR-0001');
- });
-
- it('collapses runs of whitespace', () => {
- expect(cleanupTitleSeparators('A B')).toBe('A B');
- });
-
- it('is idempotent', () => {
- const once = cleanupTitleSeparators('CTR-0001 - ');
- expect(cleanupTitleSeparators(once)).toBe(once);
- });
-});
diff --git a/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx
new file mode 100644
index 0000000000..e6d18b6761
--- /dev/null
+++ b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * 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.
+ */
+
+/**
+ * The record page H1's `titleFormat` rung renders through core's
+ * `formatTitleTemplate` — ONE interpolator (objectui#10447).
+ *
+ * ## The defect
+ *
+ * `PageHeaderRenderer` rendered the legacy template with its own `interpolate`
+ * plus a separator cleanup, while `getRecordDisplayName`'s template rung,
+ * `DetailView.resolveDisplayTitle` and `record:details`' H1 dedupe all call
+ * `formatTitleTemplate`. The two implementations had different token rules.
+ * The card's case: an expanded lookup token rendered as NOTHING in the header
+ * and as the reference's display name in core, so
+ * `titleFormat: '{account} - {deal_no}'` read `Q3-042` in the H1 while the
+ * dedupe compared `Acme - Q3-042`.
+ *
+ * ## What is pinned
+ *
+ * - THE CARD: the H1 is exactly the string core renders for the same record.
+ * - The same equality across the other token shapes the two implementations
+ * were measured to treat differently, plus the shapes they already agreed
+ * on as controls. Each expectation is core's own output, read at run time,
+ * so a change to core moves the H1 with it rather than reddening here.
+ * - A template none of whose placeholders resolves is core's empty answer, so
+ * the H1 walks on to the next rung instead of showing the template's
+ * literal text.
+ * - The option-label translation the H1 has always applied to a select token
+ * is KEPT, applied to the record before core renders it. So the H1 equals
+ * core over that labelled record, and deliberately differs from core over
+ * the raw record by exactly the label.
+ *
+ * Every fixture declares no name pointer and gives the template's fields
+ * types the type-aware derivation cannot take (or none at all), so the
+ * template is the rung that answers.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import { ComponentRegistry, formatTitleTemplate } from '@object-ui/core';
+import { ActionProvider, RecordContextProvider } from '@object-ui/react';
+// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the
+// cold transform is billed to `hookTimeout`
+// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). This keeps the
+// file in the light `dom` project instead of adding it to `heavyDomTests`.
+import '../renderers';
+
+function PageHeader({ schema }: { schema: any }) {
+ const Component = ComponentRegistry.get('page:header');
+ if (!Component) throw new Error('page:header not registered');
+ // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render
+ return ;
+}
+
+/** Render the real `page:header` for one record and return its H1 text. */
+function h1Of(objectSchema: any, record: any): string | null | undefined {
+ const { container } = render(
+
+
+
+
+ ,
+ );
+ return container.querySelector('h1')?.textContent;
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+/** Lookup-typed and number-typed fields: the derivation takes neither. */
+const dealFields = {
+ account: { type: 'lookup', reference_to: 'account', label: 'Account' },
+ deal_no: { type: 'autonumber', label: 'Deal No' },
+};
+
+const deal = (titleFormat: unknown) => ({ name: 'deal', label: 'Deal', titleFormat, fields: dealFields });
+
+describe('page:header H1 — the titleFormat rung renders what core renders (#10447)', () => {
+ it('THE CARD — an expanded lookup token renders its display name, exactly as core does', () => {
+ const titleFormat = '{account} - {deal_no}';
+ const record = { id: 'rec-1', account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042' };
+
+ const h1 = h1Of(deal(titleFormat), record);
+
+ expect(h1).toBe(formatTitleTemplate(titleFormat, record));
+ expect(h1).toBe('Acme - Q3-042');
+ });
+
+ // [name, template, record]. Every template names only fields the fixture
+ // leaves untyped or types out of the derivation, and every record resolves
+ // at least one placeholder, so core's answer is non-empty and IS the H1.
+ const shapes: Array<[string, unknown, Record]> = [
+ ['an expanded lookup with no display name drops with its separator', '{account} - {deal_no}', { account: { id: 'a1' }, deal_no: 'Q3-042' }],
+ ['a whitespace-only value drops with its separator (#10446)', '{contract_no} - {code}', { contract_no: 'HT-2026-003', code: ' ' }],
+ ['a comma left by an empty placeholder is stripped', '{deal_no}, {code}', { deal_no: 'Q3-042' }],
+ ['a double-brace token', '{{deal_no}} - {{code}}', { deal_no: 'Q3-042', code: 'X9' }],
+ ['a token with inner whitespace', '{ deal_no } - { code }', { deal_no: 'Q3-042', code: 'X9' }],
+ ['a hyphenated key', '{deal-code} {deal_no}', { 'deal-code': 'EU', deal_no: 'Q3-042' }],
+ ['the Expression envelope', { dialect: 'template', source: '{deal_no} - {code}' }, { deal_no: 'Q3-042', code: 'X9' }],
+ ['CONTROL — a dotted path into an expanded lookup', '{account.name} - {deal_no}', { account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042' }],
+ ['CONTROL — a three-part template with its middle placeholder empty', '{deal_no} - {code} - {region}', { deal_no: 'Q3-042', region: 'EMEA' }],
+ ['CONTROL — a literal prefix beside a resolved placeholder', 'Deal {deal_no}', { deal_no: 'Q3-042' }],
+ ];
+
+ for (const [name, titleFormat, values] of shapes) {
+ it(name, () => {
+ const record = { id: 'rec-2', ...values };
+ const expected = formatTitleTemplate(titleFormat as never, record);
+ // Guard the fixture, not the renderer: an empty core answer would mean
+ // the H1 came from another rung and the equality below proved nothing.
+ expect(expected).not.toBe('');
+ expect(h1Of(deal(titleFormat), record)).toBe(expected);
+ });
+ }
+
+ it('a template none of whose placeholders resolves declines, so its literal text is not the H1', () => {
+ // Core's rule: no placeholder resolved ⇒ ''. The rung takes that answer
+ // and the H1 walks on, here to the `${objectLabel} ${id}` floor, instead
+ // of printing the template's literal prefix as if it were a title.
+ const titleFormat = 'Session — {user_id}';
+ const record = { id: 'rec-00000003' };
+ expect(formatTitleTemplate(titleFormat, record)).toBe('');
+
+ const h1 = h1Of(deal(titleFormat), record);
+
+ expect(h1).not.toBe('Session');
+ expect(h1).toBe('Deal rec-0000');
+ });
+});
+
+describe('page:header H1 — a select token keeps its option label, applied before core renders (#10447)', () => {
+ const statusOptions = [
+ { value: 'in_progress', label: 'In Progress' },
+ { value: 'won', label: 'Won' },
+ ];
+ const ticket = (fields: unknown) => ({
+ name: 'ticket',
+ label: 'Ticket',
+ titleFormat: '{deal_no} · {status}',
+ fields,
+ });
+ const recordFields = {
+ deal_no: { type: 'autonumber', label: 'Deal No' },
+ status: { type: 'select', label: 'Status', options: statusOptions },
+ };
+
+ it('the H1 is core over the record with the select value read as its label', () => {
+ const record = { id: 'rec-4', deal_no: 'Q3-042', status: 'in_progress' };
+
+ const h1 = h1Of(ticket(recordFields), record);
+
+ expect(h1).toBe(formatTitleTemplate('{deal_no} · {status}', { ...record, status: 'In Progress' }));
+ expect(h1).toBe('Q3-042 · In Progress');
+ // Where it deliberately differs from core over the RAW record: the label.
+ expect(formatTitleTemplate('{deal_no} · {status}', record)).toBe('Q3-042 · in_progress');
+ });
+
+ it('the array form of `fields` maps the same way', () => {
+ const record = { id: 'rec-5', deal_no: 'Q3-042', status: 'won' };
+ const arrayFields = Object.entries(recordFields).map(([name, def]) => ({ name, ...def }));
+
+ expect(h1Of(ticket(arrayFields), record)).toBe('Q3-042 · Won');
+ });
+
+ it('a value no option declares renders raw', () => {
+ const record = { id: 'rec-6', deal_no: 'Q3-042', status: 'archived' };
+
+ expect(h1Of(ticket(recordFields), record)).toBe('Q3-042 · archived');
+ });
+
+ it('a blank select value is still empty, and drops with its separator', () => {
+ const record = { id: 'rec-7', deal_no: 'Q3-042', status: ' ' };
+
+ expect(h1Of(ticket(recordFields), record)).toBe('Q3-042');
+ });
+});
diff --git a/packages/components/src/__tests__/page-header-title.test.tsx b/packages/components/src/__tests__/page-header-title.test.tsx
index 418db085ad..3d4a052942 100644
--- a/packages/components/src/__tests__/page-header-title.test.tsx
+++ b/packages/components/src/__tests__/page-header-title.test.tsx
@@ -131,10 +131,12 @@ describe('PageHeaderRenderer — record title resolution', () => {
* `getRecordDisplayName` walks from a blank steps 1+2 to step 3;
* - an object that declares no pointer still takes its title from the
* template, ABOVE the type-aware derivation, which stays where it was;
- * - the template keeps THIS renderer's interpolation, which routes a select
- * value through its option label. Core's `formatTitleTemplate` renders the
- * raw value, so a ladder that consulted the whole unified resolver above
- * the template would change this H1 and not just the order;
+ * - the template's select value still reads as its option label. Since
+ * objectui#10447 the rung renders through core's `formatTitleTemplate`
+ * over a record copy whose select values read as their labels; the
+ * unified resolver renders the raw value, so a ladder that consulted the
+ * whole resolver above the template would change this H1 and not just the
+ * order;
* - an explicit `schema.title` still outranks both.
*/
describe('PageHeaderRenderer — the declared pointer outranks `titleFormat` (#9436)', () => {
diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx
index b728bcba62..0024c9677f 100644
--- a/packages/components/src/renderers/layout/containers.tsx
+++ b/packages/components/src/renderers/layout/containers.tsx
@@ -19,7 +19,7 @@
*/
import React from 'react';
-import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
+import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
import type { ComponentInput } from '@object-ui/core';
import { actionRendersAt, resolveDeclaredActionIds } from '@object-ui/types';
import type { DeclaredActionsRefusal } from '@object-ui/types';
@@ -321,6 +321,77 @@ const translateLabel = (
return text;
};
+/** `useSafeFieldLabel().fieldOptionLabel`'s shape. */
+type FieldOptionLabel = (objectName: string, fieldName: string, value: string, fallback: string) => string;
+
+/**
+ * The option label a user reads for one select-field VALUE, or `undefined`
+ * when `fieldName` is not an options-bearing field of `objectSchema` (or a
+ * piece needed to translate it is absent).
+ *
+ * The ONE place this file maps an enum value to its label. Two readers call
+ * it: the `{token}` interpolation below (`page:header`'s `title` /
+ * `subtitle`), and the record copy the `titleFormat` rung hands to core's
+ * `formatTitleTemplate` ({@link withOptionLabels}, objectui#10447).
+ */
+const optionLabelFor = (
+ objectSchema: any,
+ fieldOptionLabel: FieldOptionLabel | undefined,
+ objectName: string | undefined,
+ fieldName: string,
+ raw: string,
+): string | undefined => {
+ if (!objectSchema?.fields || !fieldOptionLabel || !objectName) return undefined;
+ const fieldDef: any = Array.isArray(objectSchema.fields)
+ ? objectSchema.fields.find((f: any) => f?.name === fieldName)
+ : objectSchema.fields[fieldName];
+ const options: any[] | undefined = fieldDef?.options;
+ if (!Array.isArray(options)) return undefined;
+ const match = options.find((opt: any) => String(opt?.value ?? opt) === raw);
+ const fallback = match?.label ? String(match.label) : raw;
+ return fieldOptionLabel(objectName, fieldName, raw, fallback);
+};
+
+/**
+ * A copy of `data` in which each select-field value reads as its option label
+ * ({@link optionLabelFor}); `data` itself when no value maps.
+ *
+ * Why a copy and not a hook on the formatter (objectui#10447): the record
+ * page H1's `titleFormat` rung renders through core's `formatTitleTemplate`,
+ * the one interpolator every other title surface uses. That function takes a
+ * template and a record, and nothing else. The H1 has always shown a select
+ * token as its translated label ("In Progress", not `in_progress`), so the
+ * translation happens on the record before the formatter sees it, and the
+ * formatter's signature does not move.
+ *
+ * Only a top-level scalar is mapped: `null`, `undefined`, an expanded
+ * reference and an array stay as they are, so core judges them exactly as it
+ * judges the raw record. A mapped value is never empty (the label falls back
+ * to the raw value), so the placeholders that resolve on the copy are the ones
+ * that resolve on the raw record. That is what keeps `record:details`' dedupe,
+ * which renders the same template over the raw record, deciding about the same
+ * fields the H1 shows.
+ */
+const withOptionLabels = (
+ data: any,
+ objectSchema: any,
+ fieldOptionLabel: FieldOptionLabel | undefined,
+ objectName: string | undefined,
+): any => {
+ if (!data || typeof data !== 'object') return data;
+ const source: Record = data;
+ let copy: Record | undefined;
+ for (const key of Object.keys(source)) {
+ const v = source[key];
+ if (v === null || v === undefined || typeof v === 'object') continue;
+ const label = optionLabelFor(objectSchema, fieldOptionLabel, objectName, key, String(v));
+ if (label === undefined) continue;
+ if (!copy) copy = { ...source };
+ copy[key] = label;
+ }
+ return copy ?? source;
+};
+
/**
* Replace `{field.path}` tokens in a template against the given data object.
* Missing fields collapse to an empty string. The result is trimmed and
@@ -330,12 +401,17 @@ const translateLabel = (
* resolves to a select-field value gets routed through the i18n option
* label dictionary — so `subtitle: "{industry} · {type}"` renders as
* "科技 · 客户" rather than the raw enum values "technology · customer".
+ *
+ * Callers: `page:header`'s own `title` and `subtitle`. The record-title
+ * `titleFormat` rung does NOT come through here: it renders through core's
+ * `formatTitleTemplate` (objectui#10447), because that template is the one
+ * `getRecordDisplayName` and `record:details`' dedupe render too.
*/
const interpolate = (
template: string,
data: any,
objectSchema?: any,
- fieldOptionLabel?: (objectName: string, fieldName: string, value: string, fallback: string) => string,
+ fieldOptionLabel?: FieldOptionLabel,
objectName?: string,
): string => {
if (!template || typeof template !== 'string') return template || '';
@@ -363,16 +439,9 @@ const interpolate = (
// Only the first path segment is treated as a field name (deeper
// paths reach into related records and have their own translation
// surfaces).
- if (objectSchema?.fields && fieldOptionLabel && objectName && !path.includes('.')) {
- const fieldDef: any = Array.isArray(objectSchema.fields)
- ? objectSchema.fields.find((f: any) => f?.name === path)
- : objectSchema.fields[path];
- const options: any[] | undefined = fieldDef?.options;
- if (Array.isArray(options)) {
- const match = options.find((opt: any) => String(opt?.value ?? opt) === raw);
- const fallback = match?.label ? String(match.label) : raw;
- return fieldOptionLabel(objectName, path, raw, fallback);
- }
+ if (!path.includes('.')) {
+ const label = optionLabelFor(objectSchema, fieldOptionLabel, objectName, path, raw);
+ if (label !== undefined) return label;
}
return raw;
})
@@ -1184,37 +1253,6 @@ ComponentRegistry.register('section', PageSectionRenderer, {
// (objectstack#11592 ruling, objectui#6252) — see `resolvedHeaderActions`.
// ---------------------------------------------------------------------------
-/**
- * Strip dangling connectors that survive when a `titleFormat` interpolates
- * with one side empty — e.g. `{number} - {name}` becomes `CTR-0001 -` when
- * `name` is blank. Removes a trailing/leading hyphen / middle-dot / colon /
- * slash / pipe (optionally surrounded by whitespace) and collapses
- * adjacent whitespace into a single space. Idempotent.
- *
- * Exported for unit tests.
- */
-export function cleanupTitleSeparators(s: string): string {
- if (!s) return s;
- let out = s;
- // Repeatedly trim trailing connectors. Loop so chains like " - · " all peel.
- for (let i = 0; i < 4; i += 1) {
- const next = out.replace(/[\s\u00A0]*[-·:|/–—][\s\u00A0]*$/u, '').trimEnd();
- if (next === out) break;
- out = next;
- }
- for (let i = 0; i < 4; i += 1) {
- const next = out.replace(/^[\s\u00A0]*[-·:|/–—][\s\u00A0]*/u, '').trimStart();
- if (next === out) break;
- out = next;
- }
- // Collapse double-separators in the middle (rare, but happens when the
- // middle field of a 3-part format is empty: "A - - B" -> "A - B").
- out = out.replace(/([-·:|/–—])[\s\u00A0]*\1/gu, '$1');
- // Collapse runs of whitespace.
- out = out.replace(/[\s\u00A0]+/g, ' ').trim();
- return out;
-}
-
/**
* One-time diagnostics for header-action `visible` / `hidden` predicates
* (#2358). Warn-once per (action, predicate) pair so re-renders don't spam the
@@ -2100,19 +2138,28 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => {
// Honor objectSchema.titleFormat (e.g. `{first_name} {last_name}`) as the
// rung BELOW the declared pointer. `DetailView.resolveDisplayTitle` ranks
// the two the same way, so default and synthesized record pages produce
- // the same title. The template keeps THIS renderer's interpolation (i18n
- // option labels, separator cleanup) rather than core's
- // `formatTitleTemplate`: only its rank moved in objectui#9436.
- const rawTitleFormat: any = objSchema?.titleFormat;
- const titleFormatStr: string | undefined =
- typeof rawTitleFormat === 'string'
- ? rawTitleFormat
- : (rawTitleFormat && typeof rawTitleFormat === 'object' && typeof rawTitleFormat.source === 'string')
- ? rawTitleFormat.source
- : undefined;
- const interpolatedTitleFormat = titleFormatStr
- ? cleanupTitleSeparators(interpolate(titleFormatStr, data, objSchema, fieldOptionLabel, rawObjectName).trim())
- : '';
+ // the same title.
+ //
+ // ⭐ ONE interpolator (objectui#10447). The template renders through
+ // core's `formatTitleTemplate`, the function `getRecordDisplayName`'s
+ // template rung and `record:details`' H1 dedupe call. This rung used to
+ // run this file's own `interpolate` plus a separator cleanup, with
+ // different token rules: an expanded lookup token rendered as nothing
+ // here and as its display name in core, so `{account} - {deal_no}` read
+ // `Q3-042` in the H1 while the dedupe compared `Acme - Q3-042`, and the
+ // row equal to the H1 stayed printed under it. objectui#9436 moved only
+ // this rung's rank; the rungs' order below is unchanged.
+ //
+ // The template and the Expression envelope go to core as declared (core
+ // reads both), and whatever core returns is the rung's answer: its empty
+ // string is its "nothing resolved", and there is no second test for an
+ // unresolved token. The one thing the H1 adds is the translated option
+ // label for a select value, applied to a copy of the record
+ // (`withOptionLabels`) so the formatter's signature does not move.
+ const interpolatedTitleFormat = formatTitleTemplate(
+ objSchema?.titleFormat,
+ withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName),
+ );
// Unified resolver (ADR-0079): honours the object's declared
// `nameField`/`displayNameField` and falls back to type-aware field
// derivation. `deriveFromRecordKeys: false` keeps bare record-key
@@ -2182,7 +2229,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => {
const titleCandidate =
explicitTitle ||
declaredTitle ||
- (interpolatedTitleFormat && !interpolatedTitleFormat.includes('{') ? interpolatedTitleFormat : '') ||
+ interpolatedTitleFormat ||
unifiedTitle ||
recordKeyTitle;
// Defensive backstop — deliberately last, and deliberately NOT the fix: on
diff --git a/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts b/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts
new file mode 100644
index 0000000000..971cc1bb11
--- /dev/null
+++ b/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts
@@ -0,0 +1,91 @@
+/**
+ * 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.
+ */
+
+/**
+ * `formatTitleTemplate` judges a placeholder EMPTY the way
+ * `recordDisplayValueAt` does: trim, then empty (objectui#10446).
+ *
+ * ## The defect
+ *
+ * The template renderer tested each placeholder's raw value against `null`,
+ * `undefined` and `''` on its own. A whitespace-only value passed that test, so
+ * it counted as RESOLVED and was emitted. The whitespace collapse that runs
+ * afterwards then removed the value but not the separator the empty-placeholder
+ * pass would have stripped with it: `{contract_no} - {name}` with a blank
+ * `name` rendered `HT-2026-003 -`. `getRecordDisplayName`'s `titleFormat` rung
+ * returns that string, so every resolver-backed surface showed it.
+ *
+ * `recordDisplayValueAt`, the objectui#8350 emptiness authority, trims and calls
+ * the same value empty. The two now share one implementation of that rule
+ * rather than two spellings of it.
+ *
+ * ## What is pinned
+ *
+ * - THE CARD, at both doors the card measured: `formatTitleTemplate` and
+ * `getRecordDisplayName`.
+ * - A real value is unchanged (the control: the fix must not drop values).
+ * - Agreement, value by value: a placeholder is dropped exactly when
+ * `recordDisplayValueAt` calls its field empty, and otherwise renders what
+ * that function returns. The template has a second, always-resolved
+ * placeholder so an empty first one shows as its separator being stripped,
+ * not merely as whitespace that the final trim would hide.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { formatTitleTemplate, getRecordDisplayName, recordDisplayValueAt } from '../record-title';
+
+const TEMPLATE = '{contract_no} - {name}';
+
+describe('formatTitleTemplate — a whitespace-only placeholder is empty (#10446)', () => {
+ it('THE CARD — the blank `name` drops with its separator', () => {
+ expect(formatTitleTemplate(TEMPLATE, { contract_no: 'HT-2026-003', name: ' ' })).toBe('HT-2026-003');
+ });
+
+ it("THE CARD — `getRecordDisplayName`'s titleFormat rung renders the same", () => {
+ expect(
+ getRecordDisplayName(
+ { titleFormat: TEMPLATE },
+ { id: 'c3', contract_no: 'HT-2026-003', name: ' ' },
+ ),
+ ).toBe('HT-2026-003');
+ });
+
+ it('CONTROL — a real value is unchanged, at both doors', () => {
+ const record = { id: 'c4', contract_no: 'HT-2026-003', name: 'Acme' };
+ expect(formatTitleTemplate(TEMPLATE, record)).toBe('HT-2026-003 - Acme');
+ expect(getRecordDisplayName({ titleFormat: TEMPLATE }, record)).toBe('HT-2026-003 - Acme');
+ });
+
+ it('a blank LEADING placeholder drops with its separator too', () => {
+ expect(formatTitleTemplate(TEMPLATE, { contract_no: ' ', name: 'Acme' })).toBe('Acme');
+ });
+});
+
+describe('formatTitleTemplate — a placeholder is empty exactly when `recordDisplayValueAt` says so (#10446)', () => {
+ const values: Array<[string, unknown]> = [
+ ['undefined', undefined],
+ ['null', null],
+ ["''", ''],
+ ['spaces', ' '],
+ ['no-break spaces', ' '],
+ ['padded value', ' Acme '],
+ ['0', 0],
+ ['false', false],
+ ['expanded lookup with a name', { id: 'a1', name: 'Acme' }],
+ ['expanded lookup with a blank name', { id: 'a1', name: ' ' }],
+ ['bare lookup payload', { id: 'a1' }],
+ ];
+
+ for (const [label, v] of values) {
+ it(`${label}`, () => {
+ const shown = recordDisplayValueAt({ v }, 'v');
+ const expected = shown === undefined ? 'K' : `${shown} - K`;
+ expect(formatTitleTemplate('{v} - {k}', { v, k: 'K' })).toBe(expected);
+ });
+ }
+});
diff --git a/packages/core/src/utils/record-title.ts b/packages/core/src/utils/record-title.ts
index 12f75a2eac..d7e714385e 100644
--- a/packages/core/src/utils/record-title.ts
+++ b/packages/core/src/utils/record-title.ts
@@ -150,10 +150,20 @@ function displayNameOfEmbeddedObject(o: any): string | null {
* supported. Dotted paths (`{account.name}`) walk `$expanded` lookup objects; a
* placeholder that resolves to a whole object is reduced to its display name.
*
- * Empty placeholders (missing / null / empty fields) are stripped along with
- * any orphan separator they leave behind, so `"{full_name} - {company}"`
- * evaluated against `{ company: "Acme" }` yields `"Acme"`, not `" - Acme"`.
- * Returns an empty string when no placeholder resolved.
+ * Empty placeholders (missing / null / empty / whitespace-only fields) are
+ * stripped along with any orphan separator they leave behind, so
+ * `"{full_name} - {company}"` evaluated against `{ company: "Acme" }` yields
+ * `"Acme"`, not `" - Acme"`. Returns an empty string when no placeholder
+ * resolved.
+ *
+ * What counts as "empty" is not decided here (objectui#10446). Each
+ * placeholder's value goes through {@link displayValueOf}, the body of
+ * {@link recordDisplayValueAt}: trim, then empty. This function used to test
+ * the raw value against `null` / `undefined` / `''` on its own, so a
+ * whitespace-only value counted as RESOLVED. The later whitespace collapse
+ * then left its separator dangling: `{contract_no} - {name}` with a blank
+ * `name` rendered `HT-2026-003 -`, while every value-keyed rung of
+ * {@link getRecordDisplayName} judged the same value empty.
*/
export function formatTitleTemplate(
titleFormat: string | { source?: string } | undefined | null,
@@ -180,14 +190,10 @@ export function formatTitleTemplate(
if (value == null) break;
value = (value as any)[p];
}
- if (value && typeof value === 'object') {
- value = displayNameOfEmbeddedObject(value as any);
- }
- if (value === null || value === undefined || value === '') {
- return EMPTY_TOKEN;
- }
+ const shown = displayValueOf(value);
+ if (shown === undefined) return EMPTY_TOKEN;
anyResolved = true;
- return String(value);
+ return shown;
});
if (!anyResolved) return '';
@@ -473,10 +479,28 @@ const NAME_ISH_RECORD_KEYS = [
* `{ id }` lookup payload is not a title);
* - non-strings are stringified, so `0` and `false` ARE values, not blanks;
* - `null` and `undefined` are empty, and only those two.
+ *
+ * The rule itself is {@link displayValueOf}, which {@link formatTitleTemplate}
+ * applies to each placeholder value (objectui#10446). So a `titleFormat`
+ * placeholder is empty exactly when this function would call its field empty.
*/
export function recordDisplayValueAt(record: any, field: string | undefined): string | undefined {
if (!field || !record || typeof record !== 'object') return undefined;
- let v: any = record[field];
+ return displayValueOf(record[field]);
+}
+
+/**
+ * The value half of {@link recordDisplayValueAt}: one already-read value in,
+ * its display string or `undefined` (empty) out. Trim, then empty.
+ *
+ * Module-private on purpose. It exists so the two readers in this file that
+ * judge a single value, `recordDisplayValueAt` and each placeholder of
+ * {@link formatTitleTemplate}, run ONE implementation of that judgement rather
+ * than two that agree today (objectui#10446). Read its full definition on
+ * {@link recordDisplayValueAt}.
+ */
+function displayValueOf(value: any): string | undefined {
+ let v: any = value;
if (v && typeof v === 'object') v = displayNameOfEmbeddedObject(v);
if (v === null || v === undefined) return undefined;
const s = typeof v === 'string' ? v.trim() : String(v);
diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx
new file mode 100644
index 0000000000..6e5c4e02ac
--- /dev/null
+++ b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx
@@ -0,0 +1,215 @@
+/**
+ * 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.
+ */
+
+/**
+ * The record page H1 and `record:details`' H1 dedupe render the SAME
+ * `titleFormat` string, so the dedupe hides the row that matches the H1 and
+ * no other (objectui#10447, with objectui#10446 folded in).
+ *
+ * ## The defect
+ *
+ * `page:header` rendered the legacy template with its own interpolation, while
+ * the dedupe asks core's `formatTitleTemplate` what the H1 shows. The two
+ * disagreed, so the dedupe reasoned about a heading the user never saw:
+ *
+ * - THE CARD (#10447): `{account} - {deal_no}` with an expanded `account`.
+ * The H1 dropped the lookup and read `Q3-042`; core rendered the composite
+ * `Acme - Q3-042`, which no row equals, so nothing was hidden and the
+ * "Deal No" row printed `Q3-042` directly under an H1 reading `Q3-042`.
+ * - #10446: `{contract_no} - {name}` with a whitespace-only `name`. The H1's
+ * own separator cleanup read `HT-2026-003`; core counted the blank as a
+ * value and rendered `HT-2026-003 -`, again equal to no row, so the
+ * "Contract No" row printed under an H1 showing the same value.
+ *
+ * The H1 now renders through `formatTitleTemplate` too, and core judges a
+ * whitespace-only placeholder empty. So each case asserts the H1 IS core's
+ * string for that record before it asserts which row the body drops.
+ *
+ * The one thing the H1 adds is a select value's option label. It is applied to
+ * the record before core renders it, and never changes WHICH placeholders
+ * resolve, so the dedupe (which renders over the raw record) still decides
+ * about the field the H1 shows. The last two cases pin that.
+ *
+ * ## The surface under test
+ *
+ * Each case renders the REAL `page:header` beside the REAL body under one
+ * record context, as `record-details.titleFormatPlaceholderDedupe-10360.test.tsx`
+ * does, and reads both the H1 the user sees and which row the body drops.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import * as React from 'react';
+// Registers `page:header` (and the rest of the atoms) on the shared registry.
+import '@object-ui/components';
+import { ComponentRegistry, formatTitleTemplate } from '@object-ui/core';
+import { ActionProvider, RecordContextProvider } from '@object-ui/react';
+import { RecordDetailsRenderer } from '../record-details';
+
+/**
+ * Pinned rather than inherited, for the reason
+ * `record-details.dedupeEmptinessTrims-8350.test.tsx` states: `DetailSection`
+ * can also drop an EMPTY row through a viewport-dependent auto-hide. On the
+ * desktop branch no fixture here reaches its 4-row minimum, so a row can only
+ * be missing because the dedupe dropped it.
+ */
+const DESKTOP_WIDTH = 1280;
+
+/** Render the header and the body under ONE record context; read both. */
+function renderPage(
+ record: any,
+ schema: any,
+ fields: string[],
+): { h1: string | null | undefined; body: string } {
+ const Header = ComponentRegistry.get('page:header');
+ if (!Header) throw new Error('page:header not registered');
+ const { container } = render(
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ const h1 = container.querySelector('[data-testid="page-header"] h1')?.textContent;
+ const body = container.querySelector('[data-testid="page-body"]')?.textContent ?? '';
+ return { h1, body };
+}
+
+beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: DESKTOP_WIDTH });
+ // `useRecordEditable` probes `POST /api/v1/security/explain`; happy-dom
+ // would resolve that to a REAL socket (objectui#6640). Its answer is
+ // orthogonal to which heading renders and which row is hidden.
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ allowed: true }),
+ text: async () => '{"allowed":true}',
+ })) as never);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+/**
+ * No name pointer, so the template titles the H1. The type-aware derivation
+ * lands on `deal_no` (the only text field); that only puts it on the dedupe's
+ * candidate list, it does not change the heading.
+ */
+const dealSchema = {
+ name: 'deal',
+ label: 'Deal',
+ titleFormat: '{account} - {deal_no}',
+ fields: {
+ account: { type: 'lookup', reference_to: 'account', label: 'Account' },
+ deal_no: { type: 'text', label: 'Deal No' },
+ amount: { type: 'number', label: 'Amount' },
+ },
+};
+const DEAL_FIELDS = ['account', 'deal_no', 'amount'];
+
+describe('page:header and record:details render one `titleFormat` string (#10447)', () => {
+ it('THE CARD — the H1 is core\'s composite, so no row duplicates it and "Deal No" stays', () => {
+ const record = { id: 'D1', account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042', amount: 5 };
+
+ const { h1, body } = renderPage(record, dealSchema, DEAL_FIELDS);
+
+ expect(h1).toBe(formatTitleTemplate(dealSchema.titleFormat, record));
+ expect(h1).toBe('Acme - Q3-042');
+ // The H1 is no single field's value: the dedupe hides nothing, and the
+ // body does not repeat the heading.
+ expect(body).not.toContain(h1);
+ expect(body).toContain('Deal No');
+ expect(body).toContain('Amount'); // CONTROL: the body rendered at all
+ });
+
+ it('CONTROL — a lookup with no display name drops, the H1 collapses onto `deal_no`, and that row goes', () => {
+ const record = { id: 'D2', account: { id: 'a1' }, deal_no: 'Q3-042', amount: 6 };
+
+ const { h1, body } = renderPage(record, dealSchema, DEAL_FIELDS);
+
+ expect(h1).toBe(formatTitleTemplate(dealSchema.titleFormat, record));
+ expect(h1).toBe('Q3-042');
+ expect(body).not.toContain('Q3-042');
+ expect(body).not.toContain('Deal No');
+ expect(body).toContain('Amount'); // CONTROL: the rows the H1 does not show stay
+ });
+
+ it('#10446 — a whitespace-only `name` drops with its separator in BOTH, so the "Contract No" row goes', () => {
+ const schema = {
+ name: 'contract',
+ label: 'Contract',
+ titleFormat: '{contract_no} - {name}',
+ fields: {
+ contract_no: { type: 'text', label: 'Contract No' },
+ name: { type: 'text', label: 'Name' },
+ amount: { type: 'number', label: 'Amount' },
+ },
+ };
+ const record = { id: 'C3', contract_no: 'HT-2026-003', name: ' ', amount: 13 };
+
+ const { h1, body } = renderPage(record, schema, ['contract_no', 'name', 'amount']);
+
+ expect(h1).toBe(formatTitleTemplate(schema.titleFormat, record));
+ expect(h1).toBe('HT-2026-003');
+ expect(body).not.toContain('HT-2026-003');
+ expect(body).not.toContain('Contract No');
+ expect(body).toContain('Amount'); // CONTROL: the body rendered at all
+ });
+});
+
+describe('a select token\'s option label does not move the dedupe off the field the H1 shows (#10447)', () => {
+ const ticketSchema = {
+ name: 'ticket',
+ label: 'Ticket',
+ titleFormat: '{ticket_no} - {status}',
+ fields: {
+ ticket_no: { type: 'text', label: 'Ticket No' },
+ status: {
+ type: 'select',
+ label: 'Status',
+ options: [{ value: 'in_progress', label: 'In Progress' }],
+ },
+ amount: { type: 'number', label: 'Amount' },
+ },
+ };
+ const TICKET_FIELDS = ['ticket_no', 'status', 'amount'];
+
+ it('the H1 collapses onto `status` and shows its LABEL; the Status row is the one that goes', () => {
+ const record = { id: 'T1', ticket_no: '', status: 'in_progress', amount: 7 };
+
+ const { h1, body } = renderPage(record, ticketSchema, TICKET_FIELDS);
+
+ expect(h1).toBe('In Progress');
+ expect(body).not.toContain('Status');
+ expect(body).toContain('Ticket No'); // the row the H1 does not show stays
+ expect(body).toContain('Amount'); // CONTROL: the body rendered at all
+ });
+
+ it('CONTROL — both placeholders resolve: a composite H1 with the label, and both rows stay', () => {
+ const record = { id: 'T2', ticket_no: 'TK-9', status: 'in_progress', amount: 8 };
+
+ const { h1, body } = renderPage(record, ticketSchema, TICKET_FIELDS);
+
+ expect(h1).toBe('TK-9 - In Progress');
+ expect(body).toContain('TK-9');
+ expect(body).toContain('Status');
+ });
+});