diff --git a/.changeset/data-table-header-declared-key-5351.md b/.changeset/data-table-header-declared-key-5351.md
new file mode 100644
index 0000000000..c0b154de2d
--- /dev/null
+++ b/.changeset/data-table-header-declared-key-5351.md
@@ -0,0 +1,36 @@
+---
+'@object-ui/core': minor
+'@object-ui/components': minor
+'@object-ui/plugin-dashboard': minor
+'@object-ui/plugin-detail': minor
+---
+
+`data-table` reads the declared `header`; the producers translate `label` into it.
+
+`TableColumn` declares `header: string` and does not declare `label`. The
+renderer's column normalization nonetheless read `header: col.header || col.label`,
+so the same key had one spelling the type admits and one only the runtime did.
+That alias is gone (objectui#5351), and the translation it used to perform happens
+once at each producer instead: metadata vocabulary in, adapter vocabulary out.
+
+**This narrows what `data-table` accepts, so read this if you author `data-table`
+nodes by hand.** A column spelled `{ label: 'Stage', accessorKey: 'stage' }` on a
+directly authored `data-table` now renders a **headerless** column over live
+cells. Spell it `header` — the key `TableColumn` has always declared. Columns
+reaching `data-table` through `object-data-table`, `object-grid` or a related
+list are unaffected: those producers resolve `header` for you from the spec's
+`ListColumnSchema.label`, so every spelling they accepted before they still
+accept.
+
+`@object-ui/core` gains `columnHeader()` alongside `columnIdentity()` — the reader
+producers use to cross that boundary. It is adapter-first (`header` wins over
+`label`), so an author who addressed the table directly is never overwritten.
+
+`object-data-table` also gains a fix from the same move: a column carrying a
+`label` used to render a **blank** header there even while the alias existed,
+because the widget's field-meta enrichment overwrote the authored `label` before
+the adapter ever saw it. `{ field: 'stage', label: 'Stage' }` now renders "Stage".
+
+The sibling `accessorKey: col.accessorKey || col.name` alias is **unchanged** here
+and still resolves. Retiring it is objectui#5120's remaining step, which is
+gated on two published skill guides that teach that spelling.
diff --git a/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx
new file mode 100644
index 0000000000..d6c11f5692
--- /dev/null
+++ b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * 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.
+ */
+
+/**
+ * `data-table` reads the DECLARED `header`, not the undeclared `label`
+ * (objectui#5351).
+ *
+ * `TableColumn` (`packages/types/src/data-display.ts`) declares `header: string`
+ * and `accessorKey: string`. It declares neither `label` nor `name`. The
+ * adapter's column normalization nonetheless read
+ *
+ * header: col.header || col.label
+ * accessorKey: col.accessorKey || col.name
+ *
+ * so one key had two spellings — one the type admits, one only the runtime did.
+ * That second de-facto contract is what AGENTS.md #0.1 forbids, and the
+ * maintainer ruling of 2026-08-20 settled the direction for the whole family:
+ * retire the consumer-side alias, unify the producers. `data-table` is an
+ * ADAPTER; `column-identity.ts` names its keys `TABLE_ADAPTER_COLUMN_KEY` /
+ * `TABLE_ADAPTER_HEADER_KEY` and holds the metadata fold away from them on
+ * purpose. Metadata vocabulary in, adapter vocabulary out; one translation, one
+ * place — and that place is each producer, never here.
+ *
+ * SCOPE. The `label` alias retires here; the `name` alias is HELD, and the last
+ * describe below pins it as still-read so the hold cannot be mistaken for the
+ * retirement having happened. Two published skill guides teach a directly
+ * authored `data-table` whose columns are spelled `{ name, label }`, and
+ * `skill-guide-data-table-binding.test.tsx` renders those blocks straight out of
+ * the guide files — so `name` cannot retire until the instruction corpus moves.
+ * That is objectui#5120's remaining step and is nobody's to take unbidden:
+ * `skills/**` is a customer-published surface with its own owning seat.
+ *
+ * The two aliases were DIFFERENT failure classes, which is why the cards were
+ * filed apart: an unresolved `accessorKey` gives blank cells under a live
+ * header, while an unresolved `header` gives a headerless column over live
+ * cells. Neither is dropped and neither throws — that legibility is pinned
+ * below too, because it is exactly what objectui#5349 measures against.
+ */
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+import '../data-table';
+
+const ROWS = [
+ { id: '1', stage: 'Won' },
+ { id: '2', stage: 'Lost' },
+];
+
+function renderTable(columns: unknown[]) {
+ const DataTable = ComponentRegistry.get('data-table') as any;
+ if (!DataTable) throw new Error('data-table not registered');
+ return render(
+ ,
+ );
+}
+
+/** Every rendered header cell's text, in order. */
+const headers = () =>
+ Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());
+
+/** Every rendered body cell's text, row-major. */
+const bodyCells = () =>
+ Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
+
+describe('data-table columns — the declared keys render (unchanged)', () => {
+ it('renders a column authored the declared way', () => {
+ renderTable([{ header: 'Stage', accessorKey: 'stage' }]);
+ expect(headers()).toEqual(['Stage']);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ expect(screen.getByText('Won')).toBeInTheDocument();
+ });
+});
+
+describe('data-table columns — the `name` alias is still read, and that is a HOLD (#5120)', () => {
+ it('still resolves an accessor from the undeclared `name`', () => {
+ // NOT an endorsement — a receipt. The 2026-08-20 ruling retires this limb;
+ // what stops it today is that two published skill guides teach it and
+ // `skill-guide-data-table-binding.test.tsx` renders their bytes. Pinning the
+ // CURRENT behaviour means the day the guides move, this test goes red and
+ // names itself as the thing to delete, instead of the retirement quietly
+ // never happening.
+ renderTable([{ header: 'Stage', name: 'stage' }]);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ });
+
+ it('keeps an authored `accessorKey` ahead of a divergent `name`', () => {
+ // Precedence, unchanged and load bearing: columns can arrive in the table
+ // library's own shape, and those must not be second-guessed.
+ renderTable([{ header: 'Stage', accessorKey: 'stage', name: 'nonsense' }]);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ });
+});
+
+describe('data-table columns — the undeclared `label` alias is retired (#5351)', () => {
+ it('does not resolve a header from `label`', () => {
+ // A DIFFERENT failure class from #5120's, which is why the card was filed
+ // separately: the cells are fine and the HEADER is what goes missing.
+ renderTable([{ label: 'Stage', accessorKey: 'stage' }]);
+ expect(headers()).toEqual(['']);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ });
+
+ it('keeps an authored `header` winning over a divergent `label`', () => {
+ renderTable([{ header: 'Stage', label: 'nonsense', accessorKey: 'stage' }]);
+ expect(headers()).toEqual(['Stage']);
+ });
+
+ it('LEGIBILITY: a headerless column still renders its cells and its neighbour', () => {
+ renderTable([
+ { label: 'Stage', accessorKey: 'stage' },
+ { header: 'Id', accessorKey: 'id' },
+ ]);
+ expect(headers()).toEqual(['', 'Id']);
+ expect(bodyCells()).toEqual(['Won', '1', 'Lost', '2']);
+ });
+});
+
+describe('data-table columns — the auto-width pass reads the same header key', () => {
+ it('sizes from the declared `header`, not from `label`', () => {
+ // The width pass is a SECOND read of the same columns, a few lines below
+ // the first. If the two ever spell a key differently the table measures one
+ // set of columns and renders another, so they are pinned to move together.
+ // A column's estimated width starts from its HEADER length, so a long
+ // `label` that the adapter no longer reads contributes nothing and the
+ // column falls to the 80px floor, while its declared twin does not.
+ const DataTable = ComponentRegistry.get('data-table') as any;
+ const LONG = 'A Really Quite Long Column Header';
+ render(
+ ,
+ );
+ const ths = Array.from(document.querySelectorAll('thead th')) as HTMLElement[];
+ expect(ths).toHaveLength(2);
+ expect(ths[0].style.width).toBe('80px');
+ expect(parseInt(ths[1].style.width, 10)).toBeGreaterThan(80);
+ });
+});
diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx
index ddee5e0737..8deb45bf1f 100644
--- a/packages/components/src/renderers/complex/data-table.tsx
+++ b/packages/components/src/renderers/complex/data-table.tsx
@@ -769,20 +769,53 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
// every downstream memo on each render (objectui#4618).
const data = Array.isArray(rawData) ? rawData : EMPTY_ROWS;
- // Normalize columns to support legacy keys (label/name) from existing JSONs
+ // The adapter reads the column keys `TableColumn` DECLARES. The `label`
+ // alias is gone (objectui#5351); the `name` alias is HELD, and the hold is
+ // deliberate and documented rather than an oversight.
+ //
+ // These two lines used to normalize each column as
+ // `header: col.header || col.label` and
+ // `accessorKey: col.accessorKey || col.name` — two undeclared aliases for two
+ // declared keys. `TableColumn` (`packages/types/src/data-display.ts`) declares
+ // `header: string` and `accessorKey: string`; it declares neither. So the
+ // declared surface admitted one spelling while the runtime admitted two, which
+ // is the second de-facto contract AGENTS.md #0.1 forbids, and the 2026-08-20
+ // ruling settled the direction for the whole family: retire the consumer-side
+ // alias, unify the producers.
+ //
+ // `header` has retired. Where its translation went — `columnHeader` in
+ // `@object-ui/core`, called by each producer before delivery:
+ // `ObjectDataTable.normalizeColumns` (`@object-ui/plugin-dashboard`)
+ // `RelatedList.normalizeColumn` (`@object-ui/plugin-detail`)
+ // `ObjectGrid.generateColumns` (`@object-ui/plugin-grid`, since #5068)
+ // Metadata vocabulary in, adapter vocabulary out; one translation, one place.
+ //
+ // `accessorKey || col.name` STAYS, pending objectui#5120's remaining step. It
+ // is not that the producers still need it — all three resolve `accessorKey`
+ // themselves, measured — but that two PUBLISHED skill guides teach a directly
+ // authored `data-table` whose columns are spelled `{ name, label }`:
+ // skills/objectui/guides/data-integration.md
+ // skills/objectui/guides/schema-expressions.md
+ // `skill-guide-data-table-binding.test.tsx` lifts those blocks out of the real
+ // files at run time and renders them, so retiring `name` here turns that gate
+ // red until the guides move. Retiring the runtime ahead of the instruction
+ // would leave the platform refusing a spelling it still ships, and the failure
+ // it teaches into is the illegible one below: a header over blank cells.
const initialColumns = useMemo(() => {
return rawColumns.map((col: any) => ({
...col,
- header: col.header || col.label,
- accessorKey: col.accessorKey || col.name
+ accessorKey: col.accessorKey || col.name,
}));
}, [rawColumns]);
// Auto-size columns: estimate width from header and data content for columns without explicit widths
const autoSizedWidths = useMemo(() => {
const widths: Record = {};
+ // Spelled identically to `initialColumns` above — the auto-width pass must
+ // measure the SAME columns the table renders, so the two reads move
+ // together (objectui#5351 retired `header`'s alias; `name`'s is held).
const cols = rawColumns.map((col: any) => ({
- header: col.header || col.label,
+ header: col.header,
accessorKey: col.accessorKey || col.name,
width: col.width,
fitContent: col.fitContent,
diff --git a/packages/core/src/utils/__tests__/column-identity.test.ts b/packages/core/src/utils/__tests__/column-identity.test.ts
index cf077125d1..79b6c32212 100644
--- a/packages/core/src/utils/__tests__/column-identity.test.ts
+++ b/packages/core/src/utils/__tests__/column-identity.test.ts
@@ -11,6 +11,9 @@ import {
CANONICAL_COLUMN_IDENTITY_KEY,
LEGACY_COLUMN_IDENTITY_KEYS,
TABLE_ADAPTER_COLUMN_KEY,
+ CANONICAL_COLUMN_LABEL_KEY,
+ TABLE_ADAPTER_HEADER_KEY,
+ columnHeader,
columnIdentity,
hasConflictingColumnIdentity,
normalizeColumnIdentity,
@@ -296,3 +299,71 @@ describe('conflicting-identity warning (#3104 PR3)', () => {
}
});
});
+
+/**
+ * The display half of the same boundary `TABLE_ADAPTER_COLUMN_KEY` draws for
+ * identity (objectui#5351). `data-table` declares `header` and stopped reading
+ * `label`; the spec declares `label` and never had `header`. This reader is
+ * what each producer calls to cross between them, once, before delivery.
+ */
+describe('columnHeader (#5351)', () => {
+ it('names the two keys it bridges', () => {
+ expect(CANONICAL_COLUMN_LABEL_KEY).toBe('label');
+ expect(TABLE_ADAPTER_HEADER_KEY).toBe('header');
+ // The identity boundary is a separate pair, and stays separate.
+ expect(TABLE_ADAPTER_COLUMN_KEY).toBe('accessorKey');
+ });
+
+ it('reads the spec-canonical `label`', () => {
+ expect(columnHeader({ field: 'stage', label: 'Stage' })).toBe('Stage');
+ });
+
+ it('prefers an author-supplied `header` — ADAPTER-FIRST, the opposite of columnIdentity', () => {
+ // Deliberate asymmetry. `columnIdentity` folds several METADATA spellings
+ // of one metadata concept, so the canonical metadata key wins. This reader
+ // crosses BETWEEN vocabularies: an author who wrote the adapter's own key
+ // addressed the table directly, and a producer must not overwrite that.
+ expect(columnHeader({ header: 'Stage', label: 'ignored' })).toBe('Stage');
+ // ...where identity resolution goes the other way for its own pair.
+ expect(columnIdentity({ field: 'stage', name: 'ignored' })).toBe('stage');
+ });
+
+ it('returns undefined — not the empty string — when nothing is authored', () => {
+ // So a caller can tell "no header authored" from "the header is
+ // deliberately blank" and stamp nothing rather than stamping `''`.
+ expect(columnHeader({ field: 'stage' })).toBeUndefined();
+ expect(columnHeader({ header: '', label: '' })).toBeUndefined();
+ });
+
+ it('falls through an empty `header` to a real `label`', () => {
+ expect(columnHeader({ header: '', label: 'Stage' })).toBe('Stage');
+ });
+
+ it('ignores non-string text rather than stamping an object into a header', () => {
+ expect(columnHeader({ label: { en: 'Stage' } })).toBeUndefined();
+ expect(columnHeader({ label: 42 })).toBeUndefined();
+ });
+
+ it('returns undefined for a bare-string entry — a producer derives that title', () => {
+ // A bare `'stage'` has no display text of its own. Deriving one
+ // (`humanizeFieldKey`, or the object schema's label) is the producer's job
+ // and stays there, so this reader does not invent a second convention.
+ expect(columnHeader('stage')).toBeUndefined();
+ });
+
+ it('returns undefined for non-records', () => {
+ expect(columnHeader(null)).toBeUndefined();
+ expect(columnHeader(undefined)).toBeUndefined();
+ expect(columnHeader(['stage'])).toBeUndefined();
+ });
+
+ it('leaves the identity fold untouched — the two readers never cross', () => {
+ // `normalizeColumnIdentity` writes identity keys only; it must not start
+ // manufacturing headers now that a header reader exists next to it.
+ expect(normalizeColumnIdentity({ name: 'stage', label: 'Stage' })).toEqual({
+ field: 'stage',
+ name: 'stage',
+ label: 'Stage',
+ });
+ });
+});
diff --git a/packages/core/src/utils/column-identity.ts b/packages/core/src/utils/column-identity.ts
index 0187c417ef..54c9c6a595 100644
--- a/packages/core/src/utils/column-identity.ts
+++ b/packages/core/src/utils/column-identity.ts
@@ -219,3 +219,54 @@ export function normalizeColumnIdentities(columns: T): T {
});
return (changed ? next : columns) as T;
}
+
+/**
+ * The spec's canonical key for "what this column is CALLED on screen".
+ *
+ * `ListColumnSchema` (`@objectstack/spec/view`) spells a column's display text
+ * `label`, next to its `field`. It is metadata vocabulary, exactly like
+ * {@link CANONICAL_COLUMN_IDENTITY_KEY} — not an objectui legacy spelling, and
+ * not something to amputate.
+ */
+export const CANONICAL_COLUMN_LABEL_KEY = 'label';
+
+/**
+ * NOT metadata — the display half of the same boundary
+ * {@link TABLE_ADAPTER_COLUMN_KEY} draws for identity.
+ *
+ * `header` is `TableColumn.header` (`packages/types/src/data-display.ts`), the
+ * data-table ADAPTER's own key for a column's rendered title. The adapter
+ * declares `header` and does not declare `label`; the spec declares `label` and
+ * does not declare `header`. Those are two vocabularies, and the translation
+ * between them belongs at the producer — one translation, one place
+ * (objectui#5068) — never as a tolerated `col.header || col.label` alias inside
+ * the adapter (objectui#5351).
+ */
+export const TABLE_ADAPTER_HEADER_KEY = 'header';
+
+/**
+ * Read a column entry's display text on its way INTO the data-table adapter —
+ * the header counterpart of {@link columnIdentity} (objectui#5351).
+ *
+ * Adapter-first, which is the opposite order from {@link columnIdentity} and is
+ * deliberate. `columnIdentity` folds several METADATA spellings of one metadata
+ * concept, so the canonical metadata key wins. This reader crosses a boundary
+ * between two vocabularies instead: an author who wrote the adapter's own
+ * `header` addressed the table directly, and a producer must not overwrite that
+ * — the same rule `RelatedList` and `ObjectDataTable` already state for an
+ * author-supplied `accessorKey` (objectui#5022).
+ *
+ * Returns `undefined` — not `''` — when neither key carries text, so a caller
+ * can tell "no header authored" from "the header is deliberately blank" and
+ * stamp nothing rather than stamping an empty string.
+ *
+ * A bare-string column entry has no display text of its own; producers derive
+ * one from the field key (`humanizeFieldKey`) or the object schema's label, and
+ * that derivation is theirs, not this reader's.
+ */
+export function columnHeader(entry: unknown): string | undefined {
+ if (!isRecord(entry)) return undefined;
+ return (
+ asIdentity(entry[TABLE_ADAPTER_HEADER_KEY]) ?? asIdentity(entry[CANONICAL_COLUMN_LABEL_KEY])
+ );
+}
diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx
index eacd29f707..b86a880218 100644
--- a/packages/plugin-dashboard/src/ObjectDataTable.tsx
+++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx
@@ -8,7 +8,7 @@
import React, { useState, useEffect, useContext, useMemo, useCallback } from 'react';
import { useDataScope, SchemaRendererContext, SchemaRenderer, useFilterScope } from '@object-ui/react';
-import { extractRecords, isDrillEnabled, columnIdentity } from '@object-ui/core';
+import { extractRecords, isDrillEnabled, columnIdentity, columnHeader } from '@object-ui/core';
import type { DrillDownConfig } from '@object-ui/types';
import { Skeleton, RefreshIndicator, cn } from '@object-ui/components';
import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n';
@@ -51,8 +51,10 @@ interface NormalizedColumn {
*
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
* handling both snake_case and camelCase for header generation.
- * - Object entries have their field identity RESOLVED here, at the producer,
- * and stamped onto the data-table adapter's own key.
+ * - Object entries have their field identity AND their display text RESOLVED
+ * here, at the producer, and stamped onto the data-table adapter's own keys
+ * (`accessorKey` / `header`). The adapter reads only those two, and no longer
+ * falls back to `name` / `label` (objectui#5120, objectui#5351).
*
* Object entries used to be returned raw (objectui#5120). `accessorKey` is the
* table LIBRARY's column key — `column-identity.ts` names it
@@ -74,9 +76,9 @@ interface NormalizedColumn {
* author;
* - the authored spelling is left in place, so a host reading `field` / `name`
* back off these columns keeps working;
- * - an entry with no resolvable identity is returned UNTOUCHED — nothing is
- * invented for it. It behaves exactly as it does today: a header (from
- * `header` / `label`) over empty cells, silently. Whether that silence
+ * - an entry with neither a resolvable identity nor any display text is
+ * returned UNTOUCHED — nothing is invented for it. It behaves exactly as it
+ * does today: a header over empty cells, silently. Whether that silence
* deserves a dev-time diagnostic is objectui#5349's question, and is
* deliberately NOT answered here.
*
@@ -91,10 +93,32 @@ export function normalizeColumns(columns: (string | Record)[]): Nor
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
- if (!col || col.accessorKey) return col as NormalizedColumn;
- const key = columnIdentity(col);
- if (!key) return col as NormalizedColumn;
- return { ...col, accessorKey: key } as NormalizedColumn;
+ if (!col) return col as NormalizedColumn;
+ const patch: Record = {};
+ // Identity: `accessorKey` is the adapter's key, so an author who supplied
+ // it addressed the table directly and is never second-guessed.
+ if (!col.accessorKey) {
+ const key = columnIdentity(col);
+ if (key) patch.accessorKey = key;
+ }
+ // Display text: the same boundary, seen from the label side
+ // (objectui#5351). The spec spells it `label`, the adapter spells it
+ // `header`, and the adapter no longer reads `label` — so the translation
+ // happens HERE, before delivery, or the column arrives headerless.
+ //
+ // This is a FIX as well as a move: `enrich` below spreads `buildFieldMeta`'s
+ // result over the column, and that result carries its own `label` (built
+ // from `col.header`), so an authored `label` was overwritten before it ever
+ // reached the adapter's alias. A `{ field, label }` column rendered a BLANK
+ // header here even while the alias still existed — measured, not assumed.
+ if (!col.header) {
+ const text = columnHeader(col);
+ if (text) patch.header = text;
+ }
+ // Nothing to add: return the INPUT entry by reference, so data-table's
+ // column-state re-seed stays quiet on the common path (objectui#4618).
+ if (Object.keys(patch).length === 0) return col as NormalizedColumn;
+ return { ...col, ...patch } as NormalizedColumn;
});
}
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx
new file mode 100644
index 0000000000..ce6d28ee99
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * 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.
+ */
+
+/**
+ * `ObjectDataTable` resolves a column's DISPLAY TEXT at the producer, before
+ * data-table (objectui#5351) — the header counterpart of the identity stamp
+ * objectui#5120 put in the same function.
+ *
+ * The spec spells a column's text `ListColumnSchema.label`; the data-table
+ * adapter spells it `TableColumn.header` and, since the maintainer ruling of
+ * 2026-08-20, reads only that. So the translation happens HERE, once, or the
+ * column arrives headerless.
+ *
+ * This is a FIX and not only a move, which is the part worth pinning: a
+ * `label`-spelled column rendered a BLANK header on this widget even while the
+ * adapter's alias still existed. `enrich` spreads `buildFieldMeta`'s result over
+ * every column, and that result carries its own `label` (built from
+ * `col.header`), so an authored `label` was overwritten with `undefined` before
+ * it ever reached the alias. Measured on `origin/main`, not assumed:
+ *
+ * { label: 'Stage', accessorKey: 'stage' } -> headers=[""] cells=["Won","Lost"]
+ * { field: 'stage', label: 'Stage' } -> headers=[""] cells=["Won","Lost"]
+ *
+ * Both now render "Stage".
+ */
+import { describe, it, expect, vi, beforeAll } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+
+// The REAL renderers: `@object-ui/components` registers `data-table` as an
+// import side effect, and these pins resolve through that seam rather than a
+// stand-in that would re-implement the adapter's header rule.
+import '@object-ui/components';
+
+vi.mock('@object-ui/react', async () => {
+ const actual: any = await vi.importActual('@object-ui/react');
+ return {
+ ...actual,
+ SchemaRenderer: ({ schema }: any) => {
+ const Cmp = ComponentRegistry.get(schema.type) as any;
+ if (!Cmp) throw new Error(`${schema.type} not registered`);
+ return ;
+ },
+ useDataScope: () => undefined,
+ SchemaRendererContext: actual.SchemaRendererContext,
+ };
+});
+
+import { ObjectDataTable, normalizeColumns } from '../ObjectDataTable';
+
+const ROWS = [
+ { id: '1', stage: 'Won' },
+ { id: '2', stage: 'Lost' },
+];
+
+function renderWidget(columns: unknown[]) {
+ return render(
+ ,
+ );
+}
+
+const headers = () =>
+ Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());
+const bodyCells = () =>
+ Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
+
+describe('ObjectDataTable — normalizeColumns stamps the adapter header (#5351)', () => {
+ it('maps the spec-canonical `label` onto the adapter key `header`', () => {
+ expect(normalizeColumns([{ field: 'stage', label: 'Stage' }])).toEqual([
+ { field: 'stage', label: 'Stage', accessorKey: 'stage', header: 'Stage' },
+ ]);
+ });
+
+ it('never overwrites an author-supplied `header`, even against a divergent `label`', () => {
+ // The same rule `accessorKey` gets: an author who wrote the adapter's own
+ // key addressed the table directly and is not second-guessed.
+ expect(normalizeColumns([{ field: 'stage', label: 'ignored', header: 'Stage' }])).toEqual([
+ { field: 'stage', label: 'ignored', header: 'Stage', accessorKey: 'stage' },
+ ]);
+ });
+
+ it('stamps a header even when the identity needs no stamping', () => {
+ // The two halves are independent: an `accessorKey`-spelled column with a
+ // `label` needs the header translation and nothing else.
+ expect(normalizeColumns([{ accessorKey: 'stage', label: 'Stage' }])).toEqual([
+ { accessorKey: 'stage', label: 'Stage', header: 'Stage' },
+ ]);
+ });
+
+ it('invents no header for a column that carries no display text', () => {
+ const bare = { field: 'stage' };
+ expect(normalizeColumns([bare])).toEqual([{ field: 'stage', accessorKey: 'stage' }]);
+ expect(normalizeColumns([bare])[0]).not.toHaveProperty('header');
+ });
+
+ it('returns an already-canonical entry BY REFERENCE', () => {
+ // Load bearing, not a micro-optimisation: data-table re-seeds its column
+ // state whenever the list is a new object (objectui#4618) and this widget
+ // rebuilds its node on every render. The added header branch must not cost
+ // that stability.
+ const authored = { accessorKey: 'stage', header: 'Stage' };
+ expect(normalizeColumns([authored])[0]).toBe(authored);
+ });
+
+ it('returns a wholly unresolvable entry BY REFERENCE too', () => {
+ const orphan = { width: 120 };
+ expect(normalizeColumns([orphan])[0]).toBe(orphan);
+ });
+
+ it('ignores a non-string `label` rather than stamping an object into the header', () => {
+ // `asIdentity` in `column-identity.ts` accepts only a non-empty string, so
+ // an i18n record or a stray number never becomes a header.
+ const weird = { accessorKey: 'stage', label: { en: 'Stage' } };
+ expect(normalizeColumns([weird as any])[0]).not.toHaveProperty('header');
+ });
+});
+
+describe('ObjectDataTable to data-table — the header reaches the screen (#5351)', () => {
+ beforeAll(() => {
+ expect(ComponentRegistry.has('data-table')).toBe(true);
+ });
+
+ it('renders the header of a `label`-spelled column', () => {
+ renderWidget([{ label: 'Stage', accessorKey: 'stage' }]);
+ expect(headers()).toEqual(['Stage']);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ expect(screen.getByText('Stage')).toBeInTheDocument();
+ });
+
+ it('renders header AND cells for a fully spec-spelled column', () => {
+ // Both halves of the ruling at once: `field` -> `accessorKey` (#5120) and
+ // `label` -> `header` (#5351), resolved in one place before delivery.
+ renderWidget([{ field: 'stage', label: 'Stage' }]);
+ expect(headers()).toEqual(['Stage']);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ });
+
+ it('renders a declared `header` / `accessorKey` column exactly as before', () => {
+ renderWidget([{ header: 'Stage', accessorKey: 'stage' }]);
+ expect(headers()).toEqual(['Stage']);
+ expect(bodyCells()).toEqual(['Won', 'Lost']);
+ });
+});
diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx
index 0443f42ca3..775c006c60 100644
--- a/packages/plugin-detail/src/RelatedList.tsx
+++ b/packages/plugin-detail/src/RelatedList.tsx
@@ -43,6 +43,7 @@ import type { ViewFilterRule } from '@objectstack/spec/ui';
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns } from '@object-ui/fields';
import {
columnIdentity,
+ columnHeader,
compareSortValues,
getRecordDisplayName,
getSortValue,
@@ -971,6 +972,19 @@ export const RelatedList: React.FC = ({
if (!c || !key) return c;
const patch: Record = {};
if (!c.accessorKey) patch.accessorKey = key;
+ // The display half of the SAME boundary (objectui#5351). The spec
+ // spells a column's text `ListColumnSchema.label`; the adapter spells
+ // it `TableColumn.header` and, since objectui#5120, reads only that.
+ // The alias it used to carry (`header: col.header || col.label`) is
+ // gone, so this producer translates instead — otherwise every
+ // `record_related_list` block whose columns are authored the spec way
+ // arrives headerless. An author-supplied `header` is never overwritten,
+ // for the same reason `accessorKey` isn't: it addresses the table
+ // directly.
+ if (!c.header) {
+ const text = columnHeader(c);
+ if (text) patch.header = text;
+ }
// Attach a cell renderer when it lacks one and we can resolve the
// field def — preserves any author-supplied cell/render.
if (!c.cell && !c.render) {
diff --git a/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx
new file mode 100644
index 0000000000..366a2290a7
--- /dev/null
+++ b/packages/plugin-detail/src/__tests__/RelatedList.columnHeaderLabel.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * `RelatedList` translates a column's DISPLAY TEXT into the adapter's
+ * vocabulary before delivery (objectui#5351) — the header counterpart of the
+ * identity stamp objectui#5022 put in the same function.
+ *
+ * objectui#5022 moved IDENTITY resolution here and deliberately left the header
+ * alone, because `data-table` still read `header: col.header || col.label`. The
+ * 2026-08-20 ruling retired that alias, so the other half of the translation has
+ * to move here with it. Without it, every `record_related_list` block whose
+ * columns are authored the spec way (`{ field, label }` — the shape
+ * `ListColumnSchema` declares, and the shape `record-related-list.tsx` passes
+ * straight through as `columns`) would arrive HEADERLESS: live cells under a
+ * blank title. That is a different failure from #5120's blank-cells-under-a-live
+ * -header, which is why the two were filed as separate cards.
+ *
+ * Pinned through the REAL `data-table`, because a schema-level assertion cannot
+ * see it: the column object looks complete at every RelatedList read site — it
+ * always did, which is what made the identity half silent too.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+import { RelatedList } from '../RelatedList';
+
+const fields = {
+ status: {
+ type: 'select',
+ label: 'SchemaLabel',
+ options: [
+ { value: 'planned', label: 'Planned' },
+ { value: 'running', label: 'Running' },
+ ],
+ },
+};
+
+const rows = [
+ { id: 't1', status: 'planned' },
+ { id: 't2', status: 'running' },
+];
+
+const makeDS = () => ({
+ find: vi.fn(async () => rows),
+ getObjectSchema: vi.fn(async () => ({ name: 'task', fields })),
+});
+
+function renderList(columns: any[]) {
+ return render(
+ ,
+ );
+}
+
+/** Every rendered header cell's text, in order. */
+const headers = () =>
+ Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());
+
+describe('RelatedList — the spec `label` reaches the adapter as `header` (#5351)', () => {
+ it('renders the header of a fully spec-spelled column', async () => {
+ renderList([{ field: 'status', label: 'Status' }]);
+ await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument());
+ expect(headers()).toEqual(['Status']);
+ });
+
+ it('renders the header of a legacy `name` + `label` column', async () => {
+ // `name` is objectui-side legacy that `columnIdentity` still folds; the
+ // header translation is independent of which identity spelling was used.
+ renderList([{ name: 'status', label: 'Status' }]);
+ await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument());
+ expect(headers()).toEqual(['Status']);
+ });
+
+ it('renders the header of an adapter-spelled column unchanged', async () => {
+ renderList([{ accessorKey: 'status', header: 'Status' }]);
+ await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument());
+ expect(headers()).toEqual(['Status']);
+ });
+
+ it('never overwrites an author-supplied `header` with a divergent `label`', async () => {
+ renderList([{ field: 'status', label: 'ignored', header: 'Status' }]);
+ await waitFor(() => expect(screen.getByText('Status')).toBeInTheDocument());
+ expect(headers()).toEqual(['Status']);
+ expect(screen.queryByText('ignored')).not.toBeInTheDocument();
+ });
+
+ it('leaves a column with no display text headerless — nothing is invented', async () => {
+ // Deliberately NOT derived from the field def here: the object-schema
+ // fallback belongs to the bare-string branch, which is where an author who
+ // supplied no column object at all asked for a derived title. An object
+ // column that names no text keeps rendering as it did.
+ renderList([{ field: 'status' }]);
+ await waitFor(() => expect(document.querySelectorAll('thead th').length).toBeGreaterThan(0));
+ expect(headers()).toEqual(['']);
+ expect(screen.queryByText('SchemaLabel')).not.toBeInTheDocument();
+ });
+});