Skip to content

fix(page): one page, one h1 — page:header owns the title on every page type (#3434) - #3444

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3434-master-detail-dual-h1
Aug 6, 2026
Merged

fix(page): one page, one h1 — page:header owns the title on every page type (#3434)#3444
yinlianghui merged 1 commit into
mainfrom
claude/issue-3434-master-detail-dual-h1

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3434

结论:是渲染侧的缺陷,spec 一行未改

issue 里两种可能(测试定位器过宽 vs. 应用侧重复渲染两个 h1),按 PM 的裁决用无障碍树证据判定 —— 证据指向渲染侧,e2e/live/master-detail.spec.ts 是一个诚实的探针,原样保留。

无障碍树证据

失败页面是 framework 侧的 examples/app-showcase/src/ui/pages/project-workspace.page.ts:

label: 'New Project + Tasks',        // 页面级标题
type:  'app',
regions: [{ name: 'header', components: [
  { type: 'page:header', properties: { title: 'New Project + Tasks', subtitle: '…' } },
]}, …]

两个渲染器同时输出 level-1 标题,名字完全相同:

  1. packages/components/src/renderers/layout/page.tsx:613(改动前)——
    pageType !== 'record' && (pageTitle || schema.description) 时渲染
    class="text-3xl font-bold tracking-tight text-foreground" 的页面级标题。
    PageView.tsx:131 把 spec 的 type: 'app' 映射成 pageType,所以这条对 app/home/utility 页恒成立;pageTitle = schema.title ?? schema.label 取到 label
  2. packages/components/src/renderers/layout/containers.tsx:1359 —— page:header 的非 record 分支(hasRecord === false,create 模式下没有 RecordContext 数据)在 header 元素里渲染 class="text-2xl font-semibold tracking-tight" 的标题。

在新增的 DOM 测试里逐字复现了 live 现场:

TestingLibraryElementError: Found multiple elements with the role "heading"
and name "New Project + Tasks"

  h1 class="text-3xl font-bold tracking-tight text-foreground"
  h1 class="text-2xl font-semibold tracking-tight"

—— 与 run 31069352699 的 strict mode violation 是同一对元素。不是条件互斥、也不是服务不同 region 的设计形状:两者同时出现在同一个文档、同一个页面上下文里,同名。一个文档两个 level-1 标题 = 文档大纲被打断、读屏把页面标题念两遍;当两个字符串相同时(showcase master-detail、app-showcase my-work)屏幕上还直接看到重复的一行。这在 showcase/crm 里共影响 5 个已授权页面(my-work / contact-form / page-variables / project-workspace / crm welcome)。

修法

page.tsx 里原本就写着设计意图:

Page header — suppressed on record pages (the page:header component in the header region renders the record-bound title instead)

也就是说「作者写了 page:header,标题就归它渲染」这条规则本来就存在,只是抑制条件被写死成 pageType === 'record',漏掉了其余页面类型。本 PR 把它补成完整的规则:

  • 页面 regions/body/children 里存在带标题page:header → PageRenderer 不再输出自己的隐式标题;record 页行为完全不变。
  • 判定刻意保守 —— 只有「标题会渲染出字面文本」的 header 才接管:page:header 会丢弃空标题、以及插值后为空的标题(如无 record 在作用域时的 title: '{name}',interpolate(){field} 置空)。否则抑制我们自己的标题会让页面一个 level-1 标题都没有,那是把一个 a11y 缺陷换成另一个。
  • 页面级 description 不受影响:它是页面自己的正文,不是 header subtitle 的副本,交出标题不等于删掉别的内容。

作者可见的行为变化:同时声明 label 与带标题 page:header 的页面,只显示 header 的标题(如 crm 只剩 "Welcome to the CRM",不再额外显示 "CRM Welcome")。没有 page:header 的页面完全不变。

验证

新增 packages/components/src/__tests__/page-single-h1.test.tsx —— 断言写在无障碍树上(getAllByRole('heading', { level: 1 })),而不是 class name,钉的正是 e2e 定位器依赖的那个事实。

反向验证(方向按预期,先预测后执行):预测「把 page.tsx 还原成 origin/main → 4 条正向用例转红,4 条负向对照保持绿」。实测一致:

$ git checkout origin/main -- packages/components/src/renderers/layout/page.tsx
$ pnpm exec vitest run packages/components/src/__tests__/page-single-h1.test.tsx
 × renders exactly ONE h1 when a titled page:header carries the same name
 × drops the implicit title even when the page:header names the page differently
 × honours an inline-translation map as a real header title
 × keeps the page description when the header takes over the heading
AssertionError: expected [ h1, h1 ] to have a length of 1 but got 2
 Tests  4 failed | 4 passed (8)

负向对照(「无 page:header」「header 无标题」「header 标题插值为空」「record 页」)前后都绿 —— 它们是防「零个 h1」的那一半,不能被这次改动带红,也不能因为改动而空过。

其余(均从仓根跑,flock 串行 + NODE_OPTIONS=--max-old-space-size=4096):

命令 结果
pnpm exec vitest run packages/components/src/__tests__/page-single-h1.test.tsx Test Files 1 passed / Tests 8 passed
pnpm exec vitest run packages/components Test Files 88 passed / Tests 646 passed
pnpm exec vitest run packages/app-shell/src/views/metadata-admin/previews packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts packages/layout packages/plugin-detail/src/synth Test Files 40 passed / Tests 579 passed
pnpm --filter @object-ui/components type-check 通过
pnpm --filter @object-ui/components lint 0 errors(801 warnings,全部为既有)
node scripts/check-control-bytes.mjs / check-changeset-no-major.mjs / check-changeset-fixed.mjs 通过

消费半径排查:按「规则被谁消费」而不是「改了哪个包」扫了一遍 —— page:header 的其他消费方(app-shell metadata-admin previews、pageSchemaIntrospectplugin-detailbuildDefaultPageSchemapackages/layout 的 authorable-keys、console public-contract)以及 e2e/ 下所有 spec,均无断言依赖「页面级 h1 与 header 同时存在」。e2e/live/showcase-smoke.spec.ts 只断 main 有内容,不用 heading 定位器。

真正的验收是本 PR 上 live-e2e lane 的实跑(期望 4/4 绿),会在 run 完成后回读并补充。


Generated by Claude Code

…e type (#3434)

PageRenderer rendered the page `title`/`label` as an `<h1>` on every
non-record page, while an authored `page:header` block rendered its own
`<h1>` in the header region. Both fire on the showcase master-detail page
and both say "New Project + Tasks", so the document carries two level-1
headings with the same accessible name: a broken document outline, a title
a screen reader announces twice, and a visible duplicate on screen. The
live-e2e lane reported it as a Playwright strict-mode violation —
`getByRole('heading', { name: 'New Project + Tasks' })` resolved to 2
elements, taking e2e/live/master-detail.spec.ts down in beforeEach.

Record pages already delegated the whole title block to `page:header`
("the page:header component in the header region renders the record-bound
title instead"). This states the same rule for every page type: when the
author puts a titled `page:header` in a region, that component owns the
page's h1 and PageRenderer emits none.

Delegation is conservative — only a header whose title renders literal
text counts. `page:header` drops an empty title and one that interpolates
to nothing (`title: '{name}'` with no record in scope), so a page whose
header renders no heading keeps its implicit one instead of ending up with
zero. The page-level `description` is untouched: it is the page's own
prose, not a duplicate of the header `subtitle`.

The spec locator is left exactly as it was — it was an honest probe.
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 6, 2026 5:03am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-NRJYRreB.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 21.35KB 5.70KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 4.31KB 1.89KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 479.51KB 105.39KB
core (index.js) 2.47KB 0.91KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 229.92KB 56.48KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.03KB 28.88KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.53KB 57.37KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 105.02KB 25.36KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

live-e2e lane 实跑结果:4/4 绿 ✅

run 31073012884 —— job "Live E2E (informational)" 全部步骤 success,Report failure in job summary / Upload report and server logs 两个 if: failure() 步骤均为 skipped(也就是说这不是 continue-on-error 掩盖出来的绿)。第 14 步 Run live E2E allowlist 的原文:

> playwright test --config=playwright.live.config.ts screen-flow.spec.ts action-modal.spec.ts master-detail.spec.ts

[live-e2e] authenticated as admin@objectos.ai; storageState written to e2e/live/.auth/state.json

Running 4 tests using 1 worker

  ✓  1 [chromium] › e2e/live/action-modal.spec.ts:8:1 › a row Edit action opens a modal form and closes (4.1s)
  ✓  2 [chromium] › e2e/live/master-detail.spec.ts:36:1 › Create submits the populated parent in one atomic batch (3.7s)
  ✓  3 [chromium] › e2e/live/master-detail.spec.ts:58:1 › Create with a task line includes the child op referencing the parent (5.0s)
  ✓  4 [chromium] › e2e/live/screen-flow.spec.ts:20:1 › a row flow action renders its screen and resumes the run (4.3s)

  4 passed (19.6s)

对照 #3434 里的首个真实 run(31069352699:2 passed / 2 failed):原先在 beforeEach 就因 strict mode violation 挂掉的两条 master-detail 用例,这次跑满了 3.7s / 5.0s —— 是用例主体真的执行了并通过,不是「因为不再产生任何元素所以断言空过」。定位器一行未改,它只是从命中 2 个元素变成命中 1 个。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 05:16
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 06632e9 Aug 6, 2026
18 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3434-master-detail-dual-h1 branch August 6, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[e2e] live 车道首次真跑即暴露:master-detail.spec.ts 的标题定位器命中两个 h1(strict mode violation),2/4 用例必红

2 participants