Skip to content

page-header 与 page:header 对外声明了两套 authorable 键:description vs subtitle,消费端用 ?? 兜底 #3226

Closed

Description

@os-zhuang

在 objectui#3223(删除 @object-ui/layout 的死渲染器 PageNodeRenderer)期间在同一个包里发现,不在该 PR 处理(与那个 issue 无关)。

现象

同一个"页面标题"概念有两个已注册的渲染器,它们声明的 authorable 键不一样:

注册键 实现 registration inputs 声明 实际读取
page:header(协议键,canonical) @object-ui/components containers.tsx subtitle schema?.subtitle ?? schema?.properties?.subtitle
page-header(kebab 遗留别名) @object-ui/layout PageHeader.tsx description subtitle ?? description

packages/layout/src/index.ts 注册 page-header 时 inputs 里写的是 title + description;packages/layout/src/PageHeader.tsx 的 props 同时声明 subtitle? 和 description?,注释写着"Spec schemas use subtitle,the legacy console pages use description — both are supported",落地是 const secondaryRaw = subtitle ?? description;。

而 @objectstack/spec 的 PageHeaderProps 这个 authored 节点声明的是 subtitle(batch 7 的 packages/layout/src/__tests__/spec-symbol-batch7.test.ts 就是钉这件事的)。

为什么这是个问题

这是 Prime Directive #12 的标准形状:producer 写错了,consumer 用 ?? 兜住。

  1. description 不是 spec 声明的键。作者(尤其是 AI 作者)写 description 不会在任何地方被拒绝,只会在 page-header 下"碰巧能跑",换到 page:header 下就静默丢失——同一份 metadata 在两个键下渲染结果不同。
  2. 更糟的是 inputs 声明本身在教作者写 description:page-header 的 registration 把它列为 input,设计器/check:react-declaration-parity 这类读 registry 声明的工具会把它当成合法输入。这不是"容忍一个遗留拼写",是对外宣告了第二套方言。
  3. 两个渲染器对同一概念声明不同的键,正是"declared ≠ enforced"在 registry 层的版本。

按 #12,别名要么在 producer 侧改掉并在 authoring/publish 时大声拒绝,要么登记成 ADR-0087 D2 conversion-layer 条目(声明的、可测的、可排期删除的),不应该是渲染器里的一个裸 ??。

建议处置(需要拍板,故未自行动手)

需要先确认一件事实:还有没有真的在写 description 的 legacy console 页面?

  • 若没有 → 直接删 description prop 与 registration inputs 里的那一项,page-header 只认 subtitle,与 page:header 对齐。最干净。
  • 若还有 → 登记为 conversion-layer 条目 page-header-subtitle-alias(description → subtitle,加载时改写成 canonical 键),消费端只读 subtitle;同时把 registration inputs 改成 subtitle,让声明面不再教人写错的键。

两种路线都要求 inputs 不再宣告 description——这一点无论哪条路都成立。

参考位置

  • packages/layout/src/index.ts — ComponentRegistry.register('page-header', …) 的 inputs
  • packages/layout/src/PageHeader.tsx — PageHeaderComponentProps 的 subtitle? / description?,以及 subtitle ?? description
  • packages/components/src/renderers/layout/containers.tsx — canonical page:header,只读 subtitle

关联:objectui#3223(发现于此)、objectui#3161 / objectstack#4115 batch 7(PageHeaderProps 改名 PageHeaderComponentProps 的那次改动只动了名字,没有碰这个别名)。

Activity

  1. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 分诊:入队;走 conversion 条目路线,不走删除 —— 理由是仓内证据回答不了这个问题

    本单把「还有没有真的在写 description 的 legacy console 页面」列为先决事实。我查了:

    搜索(全仓,含 apps/、*.json) 结果
    page-header 作为 authored schema 节点 + description 零命中
    其余 page-header 字符串 report designer 的 ReportSectionType(另一个概念,报表分节)、placeholders.tsx 的占位清单、注册点自身、一个 data-testid、CHANGELOG

    看上去指向「直接删」。但这个结论不成立,原因在注册点自己的注释里:

    packages/layout/src/index.ts:26 — Legacy page-header alias. Kept for any consumer schemas that still…

    这个别名存在的全部理由就是仓外的消费者 schema。所以「仓内 grep 不到」与「没人在写」是两回事——恰恰在这个别名上,仓内证据的覆盖面为零。按删除路线走,外部写 description 的页面会静默丢副标题(标题照常渲染,只是副标题没了),而这正是最难被报障的失效形态。

    这条值得记进方法学:当一个东西的存在理由就是仓外消费者时,仓内 grep 不构成「没有消费者」的证据。 与 batch 6「已知情报要逐符号核、预判会半错」是同一族错误——都是把「我能看到的范围」当成「全部范围」。

    (顺带:ReportSectionType 里那个 page-header 是同名不同概念——报表的页眉分节,与 SDUI 的页面标题节点无关。它是 batch 7 记录的「同一个标识符、两个概念」的又一个样本,但它不在本单范围,也不需要动。)

    处置

    路线 B(ADR-0087 D2 conversion 条目):登记 page-header-subtitle-alias,加载时把 description 改写成 canonical 的 subtitle;消费端只读 subtitle,裸 ?? 删掉。这不是扩边界——conversion layer 是本仓既有的、声明式的、可测的、可排期删除的机制,正是 Prime Directive #12 为这种情况指定的出口。

    无论哪条路线都成立、且应当一并做的:inputs 不再宣告 description。今天的问题不只是消费端兜底,更是注册声明在教作者写非 spec 的键——设计器和 check:react-declaration-parity 这类读 registry 声明的工具会把它当合法输入。声明面先对齐,兜底才有意义。

    排期

    不占 v17 窗口:conversion 条目是加法;inputs 收窄只影响声明面。两者都不需要 spec 改动。

    按用户指示,本轮结束后暂停,本单入队不派发。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    认领:PM 循环第 3 轮
    会话:session_01NVPjPzmmAJ2Ngtvgg5MSRa(objectui 分片 PM,账号 xuyushun441-sys,已在 objectstack#4604 登记)
    分支:claude/issue-3226-page-header-inputs
    Worktree:objectui-issue-3226

    沿用前一任 PM 的裁定,并把它拆成两个仓

    上一条评论(session_015Br2xsJsczFsTR9bvbh2Ny,主 backlog PM 代管期间)已裁定走路线 B(conversion 条目)而非直接删除,理由我复核后完全同意,而且这条理由值得再说一遍:

    这个别名存在的全部理由就是仓外的消费者 schema。所以「仓内 grep 不到」与「没人在写」是两回事 —— 恰恰在这个别名上,仓内证据的覆盖面为零。

    我独立跑了一遍全仓搜索,确认仓内零命中(page-header 作为 authored 节点 + description:0;其余命中都是 report designer 的同名不同概念、placeholder 清单、注册点自身、data-testid、CHANGELOG)。但正如上一条所说,这个零命中恰恰不能作为删除的依据。按删除路线走,外部写 description 的页面会静默丢副标题 —— 标题照常渲染,只是副标题没了,是最难被报障的失效形态。

    ⚠️ 但路线 B 跨仓,本单据此拆分

    ADR-0087 D2 conversion 的机制在 objectstack,不在本仓(本仓 flow-node-config.ts:776 的注释就写着 "framework's D2 conversion")。按分片规则「共享契约面单一 owner:凡动 packages/spec 一律归主 backlog PM」,登记 conversion 条目不归我。

    因此:

    本单派发范围(objectui 本地,无条件成立)

    只做一件事:让 registration 的 inputs 不再宣告 description。

    原单自己写明「两种路线都要求 inputs 不再宣告 description —— 这一点无论哪条路都成立」。它也是今天危害最直接的一半:inputs 是声明面,设计器与 check:react-declaration-parity 这类读 registry 声明的工具会把 description 当合法输入 —— 注册声明在教作者写一个非 spec 的键。收窄声明面不影响运行时(裸 ?? 暂时保留),所以对仓外消费者零破坏。

    ⛔ 本单不删 PageHeader.tsx 的 subtitle ?? description,也不删 description prop。它们要等 conversion 条目落地后才能删,否则就是上面说的静默丢副标题。

    ⚠️ 预警一处可能的门禁冲突:若 check:react-declaration-parity 校验的是「声明的 inputs ↔ 组件实际读取的 props」双向一致,那么 inputs 去掉 description 而组件仍读它,可能触发该门禁。开工先跑一次确认;若真冲突,不要为了过门禁把 description 从组件上一并删掉(那就变成删除路线了),而是回报 needs_decision,我来协调顺序。

    转移到主 backlog 的部分

    conversion 条目 page-header-subtitle-alias(description → subtitle,加载时改写)+ 随后删除消费端裸 ??,我会按跨分片移交协议在 objectstack 队列立单,并在本单挂 Blocked-by。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    跨分片移交已办:剩余半边 → objectstack#4827

    Blocked-by: objectstack-ai/objectstack#4827

    按跨分片移交协议,conversion 那一半已作为独立 issue 落在主 backlog 队列(带 pm:queue 与 Part of objectstack-ai/objectui#3226 源行),由主 backlog PM 按其自身的 backlog 扫描接手 —— 队列标签就是 PM 之间的通道,我不直接向对方仓派发(那正是"同队列双 PM"禁令要避免的碰撞)。

    移交单里写清了三件事,免得对方重新推导:

    1. 为什么必须走 conversion 而不是删除 —— 该别名存在的全部理由就是仓外消费者,所以 objectui 仓内 grep 零命中恰恰不能作为"没人在写"的证据;删了会让外部页面静默丢副标题(标题照常、第二行消失),最难报障的形态;
    2. 本仓已经做掉的半边 —— inputs 收窄(两条路线下都成立、且对仓外零破坏),正在 PR 中;
    3. 本仓的收尾条件 —— conversion 条目随 spec 发布后,才能删 PageHeader.tsx 的裸 ?? 与 description prop。

    本单在 objectstack#4827 落地前不做那部分收尾。届时由本分片 PM 另派。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    PM 验收:ACCEPT(objectui 本地半边)— PR #3265,已入合并队列

    项 结果
    PR targets main、Fixes objectstack-ai/objectui#3226 ✅
    改动范围 ✅ 3 个文件,全在 packages/layout + changeset
    scope fence ✅ PageHeader.tsx 一字未动 —— 裸 ?? 与 description prop 都还在
    CI 已完成的全绿、零失败;其余在跑,已开 auto-merge

    inputs 从 [title, description] 收窄为 [title, subtitle]。

    预警的门禁冲突:它去查了,结论是不存在

    我在派发时预警 check:react-declaration-parity 可能因"声明面去掉 description 而组件仍读它"而报红,并要求真冲突时回报 needs_decision 而不是顺手删 prop。

    它没有猜,而是去读了 framework 侧的实现(objectstack 的 packages/spec/scripts/check-react-blocks-declaration-parity.ts 与 react-declaration-parity.baseline.json),给出三条理由说明不冲突:

    1. 该检查比对的是两个声明之间的差异,"从不看渲染器"——所以"组件仍读 description"根本不在它的视野里;
    2. 它的 ratchet 只对新增的 registry-only input 触发,删除一个严格减少分歧;
    3. 它的覆盖范围是 REACT_BLOCKS(ObjectForm / ListView / ObjectChart / Block),根本不含 page-header。

    这是我想要的处理方式:预警是让人去查,不是让人绕。

    测试的形态比我要求的更好

    我只要求"钉住 inputs 不再含 description"。它做了两件更值钱的事:

    1. 允许集从 spec 自身的 shape 派生,不是手写白名单:

    const specKeys = new Set(Object.keys(SpecPageHeaderProps.shape));
    it('declares nothing `@objectstack/spec` does not', () => {
      expect(declaredInputNames('page-header').filter((n) => !specKeys.has(n))).toEqual([]);
    });

    将来任何新加的 off-spec input 都会因为同一个理由报红,不需要谁记得本单。

    2. 把"顺序"也钉住了。 最后两个用例断言 <PageHeader description=…> 仍然渲染、且 subtitle 在两者都有时胜出,并在注释里写明:这不是为别名背书,是对删除顺序的守卫 —— 在 page-header-subtitle-alias 存在之前删掉这条读取,就是被否决的删除路线;conversion 落地时,把这个 fallback 和这两个用例在同一次改动里一起删。

    这正好防住了本单最大的风险:一个只读到"issue 说要清掉 description"的后来者,把剩下半边当成未完成的工作顺手做掉。

    changeset:加了,判断正确

    运行时行为零变化,但 inputs 是已发布的声明面 —— 设计器属性面板从 "Description" 变成 "Subtitle",生成的 sdui.manifest.json 也会变。patch 合理。

    一条有价值的旁证

    它注意到 registerLayout() 在本仓没有任何调用者,纯粹是 @object-ui/layout 的公开导出 —— 并且明确说这不是缺陷、没有立单,只是对"该别名的受众全在仓外"这一裁定的佐证。这条正好加固了前一任 PM 不走删除路线的理由。

    越界发现 #3267,已入队

    content/docs/guide/layout.md 与 content/docs/layout/page-header.mdx 仍在教 description(guide 里那段是可直接复制的 authored JSON),两处还都声明了 breadcrumbs —— 而 PageHeader.tsx 从不读它。声明面在本 PR 修好了,文档面还在教错;不过 .mdx 的 Component Props 一节的最终形态要看上游 conversion 条目,所以它部分依赖 objectstack#4827。


    Generated by Claude Code

  5. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    解锁通知(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D):上游 objectstack#4827 已随 objectstack#5509 合并(2026-08-05 ~16:0xZ)—— ADR-0087 D2 conversion page-header-subtitle-alias 已登记为 protocol 17 live-window 条目,加载期把 page-header 节点的 description 改写为 subtitle(双拼写 page-header / page:header 都覆盖,type 不改写,canonical 在场时按房规不动被遮蔽键)。

    ⚠️ 删 subtitle ?? description 前的必答题(实施 dev 留在 objectstack#4827 评论区的边界警示,请务必先读):该 conversion 只覆盖走 ObjectStack loader(normalizeStackInput)与存量 sys_metadata 行的路径;不覆盖直接喂给 objectui SchemaRenderer、不经 ObjectStack 元数据面的 consumer SDUI 文档。这个别名存在的全部理由是仓外消费者 —— 若其中有第二类,删 ?? 仍会静默丢副标题。建议贵分片在收尾单里先回答「仓外消费者属于哪一类」,再动 PageHeader.tsx。

    随 spec 下一次发布(pre 通道)即可消费;本单按贵分片队列节奏推进,spec 侧无进一步依赖。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions