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
36 changes: 36 additions & 0 deletions .changeset/page-header-is-container-3900.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@object-ui/layout": patch
---

`page-header` 注册补 `isContainer: true` —— 校验器不再对文档承诺的 children 写法报 `not-a-container`

`PageHeader` 一直**有意**把 `schema.children` 渲染进右侧动作槽(`PageHeader.tsx:182`,
`record:quick_actions` 嵌在 `page:header.children` 下就是靠它),
`content/docs/layout/page-header.mdx` 把该槽的优先级(`action` → React `children` →
`actions` → schema children)写成公开契约,该文档页唯一的 live demo
(`layout-page-header/pageheader-with-actions`)正是这个形状且实测正常渲染。但
`packages/layout/src/index.ts` 的注册漏了 `isContainer: true`。

漏这个 flag 从来没有挡住任何渲染 —— 渲染路径根本不读它(`SchemaRenderer` 把
`children` 从 React props 里剥掉当元数据,而始终把整个节点作为 `schema` 传下去,
`PageHeader` 自己再把 `schema.children` 放回槽里)。它的消费者在别处:`sdui-parser`
的 `not-a-container` 诊断、Studio 调色板元数据、react-page 标签表。所以真正的后果是
**校验器在说谎**:作者照文档写出能正常渲染的 schema,却拿到一条
"`page-header` does not accept children" 的 warning;信了这条 warning 去掉 children,
右槽就空掉。而会说谎的 warning 比缺一条 warning 更贵 —— 它训练作者(尤其 AI 作者)
连真实的 `not-a-container`(那些确实不收子节点的组件)一起无视。

这不是在 spec 之外新开作者面:`children` 是 objectui JSON 协议里**每个节点**的基础属性
(`sdui-parser/src/validate.ts` 的 `BASE_PROPS` 把它和 `type`/`id`/`className` 并列),
不是 `PageHeaderProps` 的键。所以这个 flag 回答的是协议层面的"该节点是否接受子节点列表",
而对这个组件,答案一直是"是"。维护者 2026-08-09 就 objectui#3900 的 A/B 分叉裁定 A 案,
理由同上。

行为面变化极窄:注册元数据一个布尔位。渲染输出逐字节不变(渲染路径不读该 flag);
`sdui-parser` 对带 children 的 `page-header` 少报一条 warning;设计器把它当容器对待
(即它本来的样子)。canonical 的 `page:header`(`@object-ui/components`)不在此列且刻意不动
—— 那个渲染器完全不读 `schema.children`,所以它没有 `isContainer` 是正确的。

两个方向都已钉住:文档 demo 走应用真实构建的 manifest 后不再产生 `not-a-container`,
而一个真正不收子节点的组件(`navigation-renderer`)带 children 时诊断照旧触发 ——
后者是前者的对照,保证这条修复不是把诊断弄哑了。
1 change: 1 addition & 0 deletions examples/schema-catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@object-ui/core": "workspace:*",
"@object-ui/layout": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/sdui-parser": "workspace:*",
"typescript": "^6.0.3"
}
}
110 changes: 98 additions & 12 deletions examples/schema-catalog/test/pageheader-with-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,31 @@
* rendering, and it held a third, already-drifted copy of the component's
* spacing numbers (objectui#3786 fixed the second copy, in the prose).
*
* Two things are pinned here, and they are different facts:
* Three things are pinned here, and they are different facts:
*
* 1. SHAPE — the example's root node is a `page-header`, and it contains none
* of the class strings that only exist inside `PageHeader.tsx`. This is the
* regression that would fire if anyone hand-rolls the header again.
* 2. RENDER — driven through the real `SchemaRenderer`, the node produces the
* header: an `<h1>` title, the subtitle, and BOTH schema children in the
* right-hand slot.
* 3. VALIDATE — the same JSON, put through the manifest the app really builds
* from the live registry, draws no `not-a-container` diagnostic (#3900).
*
* (2) is worth a test rather than an eyeball because the registration for
* `page-header` (`packages/layout/src/index.ts`) does NOT declare
* `isContainer: true`, which reads like children could not reach the slot.
* They do: `isContainer` is registry metadata that the render path never
* consults (its consumers are `sdui-parser`'s `not-a-container` diagnostic, the
* Studio palette, and the react-page tag map). `SchemaRenderer` strips
* `children` from the React props but always passes the whole node as `schema`,
* and `PageHeader` re-introduces `schema.children` itself. That is a load-
* bearing coincidence of two files, so it gets a pin.
* (2) and (3) are two halves of one contradiction that used to be live. The
* render path never consults `isContainer` — `SchemaRenderer` strips `children`
* from the React props but always passes the whole node as `schema`, and
* `PageHeader` re-introduces `schema.children` itself — so the header rendered
* its children correctly while `packages/layout/src/index.ts` omitted
* `isContainer: true` from the registration. The flag's consumers are elsewhere:
* `sdui-parser`'s `not-a-container` diagnostic, the Studio palette, and the
* react-page tag map. So the omission never blocked anything; it made the
* VALIDATOR contradict the docs, the demo and the render — an author following
* this very page got a warning telling them their working schema was invalid.
* #3900 added the flag (maintainer ruling, route A: `children` is a base
* property of every protocol node, not a `PageHeaderProps` key, so declaring it
* mints no authoring surface outside the spec). (3) is what keeps the two faces
* from drifting apart again in either direction.
*
* Module-scope imports, not `beforeAll` (AGENTS.md §测试纪律): the child
* `button` node resolves through `@object-ui/components`' registration
Expand All @@ -37,8 +44,11 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@object-ui/components';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
import { registerLayout } from '@object-ui/layout';
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser';
import { getExample } from '../src/index.js';

const EXAMPLE_ID = 'layout-page-header/pageheader-with-actions';
Expand All @@ -59,6 +69,25 @@ beforeAll(() => {
registerLayout();
});

/**
* The manifest the running app validates against, built the way the app builds
* it — keyed by every KNOWN registry tag (bare and namespaced) rather than by
* `getAllConfigs()`, whose `.type` is always the namespaced form. Mirrors
* `getJsxManifest()` in `packages/components/src/renderers/layout/page.tsx`
* (module-private, hence the four lines here): key it off `getAllConfigs()`
* instead and the bare `page-header` tag authors write is absent from the
* manifest, so every assertion below would pass on `unknown-component` and
* never reach the containment check at all.
*/
const diagnose = (schema: unknown): Diagnostic[] => {
const configs = ComponentRegistry.getKnownTypes().map((t) => {
const meta = ComponentRegistry.getMeta(t);
return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs };
});
const manifest = manifestFromConfigs(configs as unknown as Parameters<typeof manifestFromConfigs>[0]);
return validateTree(schema as SchemaElement, manifest).diagnostics;
};

describe('the page-header docs demo uses the page-header component (#3787)', () => {
it('is rooted at a `page-header` node', () => {
const schema = getExample(EXAMPLE_ID).schema as { type?: string };
Expand Down Expand Up @@ -88,8 +117,10 @@ describe('the page-header docs demo uses the page-header component (#3787)', ()
expect(h1?.textContent).toBe('Users');
expect(screen.getByText('Manage your team members and permissions')).toBeTruthy();

// Both children reach the right-hand slot despite the registration not
// declaring `isContainer` — see the module header.
// Both children reach the right-hand slot. This held even while the
// registration omitted `isContainer` — the render path never reads the flag
// (see the module header), which is why the omission was invisible here and
// only the validator complained.
expect(screen.getByRole('button', { name: 'Export' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Add User' })).toBeTruthy();
});
Expand All @@ -114,3 +145,58 @@ describe('the page-header docs demo uses the page-header component (#3787)', ()
expect(cls).not.toMatch(/\b(sm|md|lg|xl):pb-/);
});
});

/**
* The validator agrees with the docs, the demo and the render (#3900).
*
* Two directions, and BOTH are load-bearing. Asserting only the first would
* leave the suite green if someone silenced the containment check outright, or
* flipped `isContainer` on by default — the test would then be measuring
* nothing. The second case is the control that keeps the first one meaningful.
*/
describe('the documented demo validates clean of `not-a-container` (#3900)', () => {
const CONTAINMENT = 'not-a-container';

it('reports no `not-a-container` for the demo the docs page ships', () => {
const schema = getExample(EXAMPLE_ID).schema as { children?: unknown[] };
const diagnostics = diagnose(schema);

// Reachability BEFORE the absence — an empty result proves nothing if the
// containment branch never ran. Two ways it silently would not:
// `validateTree` reports `unknown-component` and returns before the
// containment check when the tag is missing from the manifest, and the
// branch is guarded by `node.children?.length`, so a demo that lost its
// children would satisfy the assertion below for the wrong reason.
expect(diagnostics.filter((d) => d.code === 'unknown-component')).toEqual([]);
expect(schema.children?.length ?? 0).toBeGreaterThan(0);

// Filtered by code, not asserted against an empty diagnostic list: the demo
// also writes `icon`, which the registration's `inputs` still omits even
// though `PageHeader.tsx:224` renders it, so a live `unknown-prop` sits in
// this list. That is the same family as #3900 on a different key, filed
// separately as objectui#3972 and deliberately NOT pinned here — this test
// must not go red when that one is fixed.
expect(diagnostics.filter((d) => d.code === CONTAINMENT)).toEqual([]);
});

it('still reports `not-a-container` for a component that genuinely takes none', () => {
// The control. `navigation-renderer` (registered in the SAME file as the
// #3900 change) is driven entirely by its `items` prop and never reads
// `schema.children`, so children under it ARE an authoring mistake and the
// author must still hear about it. If this component ever legitimately
// becomes a container, this assertion goes red — move the control to
// another childless registration rather than deleting it.
// `items` is deliberately omitted (it is not `required`, so its absence
// draws nothing): this control is about containment only, and writing
// `items: []` would also draw a `type-mismatch` — the registration declares
// that prop `type: 'object'` while `NavigationRendererProps.items` is
// `NavigationItem[]`, a separate declaration-face defect filed as
// objectui#3972.
const codes = diagnose({
type: 'navigation-renderer',
children: [{ type: 'button', label: 'Nope' }],
}).map((d) => d.code);

expect(codes).toContain(CONTAINMENT);
});
});
30 changes: 30 additions & 0 deletions packages/layout/src/__tests__/page-header-authorable-keys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,36 @@ describe('the `page-header` registration declares the spec key, not a dialect',
});
});

describe('the `page-header` registration declares the child slot it renders (objectui#3900)', () => {
// Same principle as the `inputs` narrowing above, other direction: the
// declaration face must not DENY a surface the component serves either.
// `PageHeader.tsx:182` deliberately renders `schema.children` into the
// right-hand slot, `content/docs/layout/page-header.mdx` publishes that slot's
// precedence, and the docs page's only live demo is exactly that shape — while
// the registration omitted `isContainer`, so `sdui-parser`'s `not-a-container`
// diagnostic fired on it. Nothing on the render path reads the flag, so the
// omission broke no rendering; it made the validator tell authors (AI authors
// especially) that a documented, demo-verified schema was invalid, which is
// how the true `not-a-container` reports lose their credibility.
//
// Not an extension of the spec's authoring surface: `children` is a base
// property of every node in objectui's JSON protocol (`BASE_PROPS` in
// `sdui-parser/src/validate.ts`), not a key of `PageHeaderProps` — hence the
// `specKeys` cross-check above neither covers nor contradicts this.
//
// The end-to-end half of this pin — the real demo JSON through the manifest
// the app actually builds, plus the control proving the diagnostic still fires
// for a genuinely childless component — lives in
// `examples/schema-catalog/test/pageheader-with-actions.test.tsx`, next to the
// fixture it validates.
it.each([
['page-header', undefined],
['page-header', 'layout'],
])('marks %s (namespace: %s) as a container', (type, namespace) => {
expect(ComponentRegistry.getConfig(type, namespace)?.isContainer).toBe(true);
});
});

describe('the runtime `description` fallback stays until the conversion entry lands', () => {
// NOT an endorsement of the alias — a guard on the ORDER. Removing this read
// before `page-header-subtitle-alias` exists is the deletion route that was
Expand Down
34 changes: 27 additions & 7 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,34 @@ export function registerLayout() {
// which lives in the framework repo. Narrowing the DECLARATION is
// unconditional and independent of that: it breaks no consumer, and it stops
// the registry from teaching the wrong key in the meantime.
//
// `isContainer: true` is the other half of the same principle, and it is NOT
// an extension of the spec's authoring surface (objectui#3900). `children` is
// a base property of EVERY node in objectui's JSON protocol — `BASE_PROPS` in
// `sdui-parser/src/validate.ts` lists it beside `type`/`id`/`className` — not
// a key of the spec's `PageHeaderProps`. So this flag answers the protocol
// question "does this node accept a child list?", and for this component the
// answer has always been yes: `PageHeader.tsx` deliberately re-introduces
// `schema.children` into the right-hand slot (that is how `record:quick_actions`
// nests under `page:header.children`), `content/docs/layout/page-header.mdx`
// publishes the slot's precedence as contract, and the docs page's only live
// demo is exactly that shape.
//
// Leaving the flag off did not make children illegal — nothing on the render
// path reads `isContainer` — it made the VALIDATOR lie: `sdui-parser`'s
// `not-a-container` diagnostic fired on the documented, demo-verified,
// correctly-rendering write-up. A warning that lies is worse than a missing
// one, because it trains authors (AI authors especially) to discount the true
// `not-a-container` reports on components that really are childless.
ComponentRegistry.register('page-header', PageHeader, {
namespace: 'layout',
label: 'Page Header',
category: 'Layout',
inputs: [
{ name: 'title', type: 'string', label: 'Title' },
{ name: 'subtitle', type: 'string', label: 'Subtitle' }
]
namespace: 'layout',
label: 'Page Header',
category: 'Layout',
isContainer: true,
inputs: [
{ name: 'title', type: 'string', label: 'Title' },
{ name: 'subtitle', type: 'string', label: 'Subtitle' },
],
});

// Page Card — register ONLY as `layout:page:card`. `skipFallback` keeps this
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading