Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/pageheader-title-optional.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": minor
---

fix(spec): `PageHeaderProps.title` is optional — matches the platform's own synthesized header (#7702)

`PageHeaderProps.title` was declared **required**, but the platform's own
synthesizer (objectui `buildDefaultHeader`) emits every seeded `page:header`
with **no `title` at all** — `{ type: 'page:header', recordChrome, …actions }`.
`PageHeaderRenderer` (`containers.tsx`) reads
`schema?.title ?? schema?.properties?.title` and, finding neither, falls
through to the record chip's own record-derived heading: a static authored
title would be wrong on every record but one. `PageHeaderProps.safeParse`
therefore rejected the platform's own canonical output with `title: Invalid
input` — invisible on the write path today (`PageComponent.properties` is an
opaque `z.record`), but a standing contradiction that surfaces the moment any
props-level validation runs against a header node (`validateComponentProps`,
#5068, is exactly that consumer).

Maintainer ruling 2026-08-11 (accepting the spec lane's A/B recommendation,
rejecting a sentinel-value option C): `title` becomes optional, and its
describe states the sanctioned spelling — **title omitted ⇒ the renderer
derives the heading from the record**. Authors still set it explicitly on
non-record pages (dashboards, landing pages) where there is no record to
derive a heading from.

This is a widening change: every payload that validated before (with `title`)
still validates identically, and `title`, when present, still parses as
`I18nLabelSchema` exactly as before. The only newly-accepted shape is a
`page:header` with `title` omitted — the platform's own default. Minor, not
patch, because the accepted-input surface grows.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const result = AIChatWindowProps.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **title** | `string \| Record<string, string>` | | Page title |
| **title** | `string \| Record<string, string>` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. |
| **subtitle** | `string \| Record<string, string>` | optional | Page subtitle |
| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **breadcrumb** | `boolean` | ✅ | Show breadcrumb |
Expand Down
20 changes: 20 additions & 0 deletions packages/lint/src/validate-component-props.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,26 @@ describe('validateComponentProps — value verdicts', () => {
);
});

/**
* #7702 — `PageHeaderProps.title` used to be required while the platform's
* own synthesizer (objectui `buildDefaultHeader`) emits every seeded
* `page:header` with NO `title` at all: `{ type: 'page:header', recordChrome
* }`. `validateComponentProps` (this rule) would therefore flag the
* platform's own default header as `component-props-invalid` on every
* synthesized record page. Maintainer ruling 2026-08-11: `title` is
* optional. This pins the exact synthesized shape as clean — no `invalid`
* finding for a missing `title`, ever.
*/
it('does not flag the synthesized page:header (no title) as invalid (#7702)', () => {
const findings = validateComponentProps(
stackWith([{ type: 'page:header', properties: { recordChrome: true } }]),
);
expect(
invalid(findings).filter((f) => f.path.endsWith('.properties.title')),
).toEqual([]);
expect(findings).toEqual([]);
});

/**
* `ElementDataSourceSchema` is the component-node binding that "overrides
* page-level object context", and objectui's element renderers read it FIRST
Expand Down
24 changes: 22 additions & 2 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,8 +43,28 @@ describe('PageHeaderProps', () => {
expect(result.actions).toHaveLength(2);
});

it('should reject header without title', () => {
expect(() => PageHeaderProps.parse({})).toThrow();
// #7702, maintainer ruling 2026-08-11: `title` is OPTIONAL. The platform's
// own synthesizer (objectui `buildDefaultHeader`) emits every seeded
// `page:header` with no `title` — the renderer falls through to the
// record-derived heading. `PageHeaderProps.safeParse` on that exact
// emission shape must succeed; it used to fail with `title: Invalid input`.
it('accepts a header without title — the synthesized shape (#7702)', () => {
// objectui `buildDefaultHeader`'s real emission: `{ type: 'page:header',
// recordChrome, ...(actions?) }` — no `title` key at all.
const result = PageHeaderProps.parse({ recordChrome: true });
expect(result.title).toBeUndefined();
expect(result.recordChrome).toBe(true);
});

it('accepts a completely empty header — every field optional or defaulted', () => {
const result = PageHeaderProps.parse({});
expect(result.title).toBeUndefined();
expect(result.breadcrumb).toBe(true);
});

it('still validates a present title as an I18nLabel', () => {
expect(() => PageHeaderProps.parse({ title: 42 })).toThrow();
expect(PageHeaderProps.parse({ title: 'My Page' }).title).toBe('My Page');
});
});

Expand Down
17 changes: 16 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,7 +222,22 @@ export type PageContainerProps = z.input<typeof PageContainerProps>;
*/

export const PageHeaderProps = z.object({
title: I18nLabelSchema.describe('Page title'),
/**
* Page title (#7702, maintainer ruling 2026-08-11 「接受你的建议,开始加速处理」
* on the lane's A/B recommendation). OPTIONAL, not required: the platform's
* own synthesizer (objectui `buildDefaultHeader`) emits every seeded
* `page:header` with no `title` at all — `PageHeaderRenderer`
* (`containers.tsx:1013`) reads `schema?.title ?? schema?.properties?.title`
* and, finding neither, falls through to the record chip's own
* record-derived heading. A required `title` would reject the platform's
* own canonical output. Sanctioned spelling: title omitted ⇒ the renderer
* derives the heading from the record. Authors still set it explicitly for
* non-record pages (dashboards, landing pages) where there is no record to
* derive from.
*/
title: I18nLabelSchema.optional().describe(
'Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from.',
),
subtitle: I18nLabelSchema.optional().describe('Page subtitle'),
/**
* REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829,
Expand Down
Loading