Skip to content

fix(plugin-kanban): the card title-dedupe skip set reads the shared name-field resolver, not a key nothing emits - #8433

Merged
hotlong merged 1 commit into
mainfrom
fix/kanban-name-field-resolver-8400
Sep 7, 2026
Merged

fix(plugin-kanban): the card title-dedupe skip set reads the shared name-field resolver, not a key nothing emits#8433
hotlong merged 1 commit into
mainfrom
fix/kanban-name-field-resolver-8400

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Kanban cards printed the record title twice — once as the card heading, once as the first row of the card body.

Root cause

ObjectKanban resolves each heading through ADR-0079's getRecordDisplayName, then builds titleFieldsToSkip so the title field's raw value is not rendered again as a card field. packages/plugin-kanban/src/ObjectKanban.tsx:418 fed that set from objectDef?.NAME_FIELD_KEY — a key nothing produces, so the set collapsed to its five hard-coded literals and the title field flowed straight into the body.

The three premises, re-verified independently (not taken from the issue)

Claim How it was checked Result
The framework never emits NAME_FIELD_KEY git grep -i NAME_FIELD_KEY in objectstack at the cloud pin afbf2711 and at framework main; positive control nameField matches in both 0 hits, both trees
Nor does the published package grep -ra over node_modules/@objectstack/spec@17.0.0 (dist + json-schema + api-surface) 0 hits; the zod object schema declares nameField and displayNameField
The skip set is therefore always empty of the real name field Read ObjectKanban.tsx:418 → :542-549 → :568; titleFieldsToSkip is consulted only in the explicit-cardFields branch, and its other members are explicitTitleField (unset unless the view declares cardTitle/titleField) plus 5 literals Chain holds — no other fallback repairs it

resolveNameField() (packages/core/src/utils/record-title.ts:346) is this repo's shared name-space resolver, and it is correct: it delegates the declared ladder to declaredNameField() (:320, nameField ?? displayNameField ?? NAME_FIELD_KEY — the identical ?? chain getRecordDisplayName reads at :551) and then falls through to deriveTitleField().

Blast radius: the repo-wide grep

NAME_FIELD_KEY has exactly two read sites in source:

  • packages/core/src/utils/record-title.ts:321 — the correct back-compat fallback. Left alone.
  • packages/plugin-kanban/src/ObjectKanban.tsx:418 — the defect. Fixed.

Every other occurrence is prose (docblocks, changesets) or the record-title alias test. The only sibling of this dedupe, record-details.tsx, was already moved onto the shared resolver by objectui#8175, so no third site needs the same change.

Why one rung, not two

objectui#8175 unrolls the same ladder into two candidates — resolveNameField() and deriveTitleField() — so a declared-but-blank pointer still dedupes against the derivation its value-keyed header fell through to. That second rung is deliberately not copied here: record-details filters a synthesized field list, whereas this set filters an author-declared cardFields. Carrying the derivation alongside a declared pointer would drop a field the author explicitly asked for whenever the two disagree (nameField: 'code' titles the card while the derivation answers owner_name) — on a four-field card that is a worse failure than a repeated title. The fourth test pins that boundary and goes RED if the second rung is ever added.

Ablation (measured, both directions)

New file: packages/plugin-kanban/src/__tests__/ObjectKanban.nameFieldSkipSet-8400.test.tsx, written to the AI-built shape from the report — an object with nameField: 'visit_title' and a board listing visit_title in cardFields. Assertions count occurrences rather than asserting presence, because the defect is a duplicate and an absence assertion would bless the wrong fix.

Fix reverted (one token: resolveNameField(objectDef)objectDef?.NAME_FIELD_KEY):

 × a DECLARED `nameField` listed in `cardFields` renders once, as the heading only 45ms
 × a DERIVED name field (no `nameField` declared) is deduped too, and does not get worse 12ms
 × does NOT hide a card field that merely LOOKS name-ish while another field is declared 10ms
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 2 to be 1 // Object.is equality
 Test Files  1 failed (1)
      Tests  3 failed | 1 passed (4)

Fix restored:

 Test Files  1 passed (1)
      Tests  4 passed (4)

The one test green in both runs is the positive control (pet_name / visit_at still render) — without it every count assertion would be satisfied by a board that dropped the body entirely.

Wider verification

  • vitest run packages/plugin-kanban/31 files / 208 tests, all pass
  • vitest run packages/core/src/utils packages/plugin-view packages/plugin-detail — 224/225 files pass. The one failure is core/src/utils/__tests__/date-display.optionsStyle-7745.test.ts (2 assertions), pre-existing and timezone-dependent: it is green under TZ=UTC (15/15) and this diff does not touch that file.
  • turbo run build --filter=@object-ui/plugin-kanban... — 13/13 packages build (this is the real typecheck path; bare tsc -p in the package cannot resolve workspace @object-ui/* types without the build).
  • eslint on both changed files — 0 errors (only the file's pre-existing no-explicit-any warnings).

Note on the upstream attribution

cloud#2073 attributed this to nameField not being persisted into sys_metadata. That attribution is wrong and withdrawnobjectDef carries the correct answer at this call site; this one line simply did not read it.

Closes #8400

🤖 Generated with Claude Code

…ame-field resolver, not a key nothing emits

A kanban card printed its record title twice — once as the card heading, once
as the first row of the card body.

`ObjectKanban` resolves each heading through ADR-0079's `getRecordDisplayName`,
then builds `titleFieldsToSkip` so the title field's raw value is not rendered
again as a card field. That skip set read `objectDef.NAME_FIELD_KEY`, a key
NOTHING produces: `@objectstack/spec@17`'s object schema declares `nameField`
(canonical) and `displayNameField` (its deprecated alias), and `NAME_FIELD_KEY`
occurs 0 times in the framework tree and 0 times in the published package —
this repo reads it only as the last rung of `declaredNameField` in
`record-title.ts`, and never emits it.

So the read was always `undefined` and the skip set collapsed to its five
hard-coded literals (`name` / `full_name` / `title` / `subject` /
`display_name`). Any object whose name field is spelled otherwise duplicated
its title, which made the defect universal on AI-built apps — whose objects
name fields `visit_title`, `owner_name`, `<entity>_name` — and invisible on
hand-built objects whose name field is literally `name`.

The skip set now reads `@object-ui/core`'s `resolveNameField`, the name-space
twin of the resolver that produced the heading, so the two agree on WHICH field
titles the object.

Deliberately ONE rung, unlike the same dedupe in `record-details.tsx`
(objectui#8175), which also carries `deriveTitleField`: that ladder filters a
SYNTHESIZED field list, whereas this one filters an AUTHOR-DECLARED
`cardFields`, where dropping a field the author asked for is a worse failure
than a repeated title. The fourth test is the guard — an object whose declared
(`code`) and derived (`owner_name`) pointers disagree — and it goes RED if the
second rung is ever added.

Closes #8400

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.5 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DCIBTJ-m.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
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) 498.55KB 114.03KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.68KB 63.94KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.39KB 15.71KB
plugin-list (index.js) 113.38KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@hotlong

hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

补上你声明缺的那一块:活栈界面证据

你在汇报里如实写了「测试是 jsdom 里真实渲染看板卡片 DOM,不是活栈截图……如果你要界面证据,需要在 rig 上跑一遍」。我跑了,修复在真实栈上生效

装置:把本分支(a347fec94)构建成 console 产物,overlay 到本地 prod-like 栈上,打开的是开这张卡时用的同一个环境和同一个应用(AI 搭建的宠物医院,sdbh_visit_record.visit_status_kanban)。确认过 overlay 真的生效:rig 的 console bundle 里 NAME_FIELD_KEY 只剩 1 处 —— 就是 record-title.ts 那个你没动的向后兼容兜底。

DOM 计数(不是看截图数)

// 每个标题字符串在叶子节点里出现的次数
{ "小虎犬牙洁治评估": 1 },   // 修复前 2
{ "豆包皮肤瘙痒复诊": 1 }    // 修复前 2

卡片正文完整性(证明没有过滤过头):

小虎犬牙洁治评估
宠物 可乐
就诊时间 2026/9/7 上午9:30
主诉 口气较重,牙龈容易出血,主人希望评估是否需要进行洁牙。
接诊医生 Seed Admin

标题一次、其余四个字段一个不少。对照 issue 正文里修复前那张卡(标题连着印两遍、然后才是「可乐」),这是同一张卡的前后对照。

你那个「只用一级梯子」的判断,我同意

record-details.tsx#8175)过滤的是合成字段表,看板过滤的是**作者声明的 cardFields`` —— 当 nameField` 指向的字段与派生结果不一致时,第二级梯子会把作者点名要显示的字段从卡片上抹掉。四字段卡片上那确实比标题重复更糟。你为此单独写了一条会因加第二级而变红的测试,这是对的做法。

两处你纠正我的地方,我确认收到

  1. objectui 仓没有 .objectstack-sha(那在 cloud),我转述时说错了;你改查三处(pin / framework main / 实际依赖的 @objectstack/spec@17.0.0)比原本的要求更严。
  2. ?? 链在 declaredNameField() 而非 resolveNameField()。这个区别是实质的 —— 正是 resolveNameField() 多出的那层 deriveTitleField() 兜底,让「未声明 nameField」的对照用例也一并变好,而不只是不变坏。

CI 还在跑(Lint / Type Check / 4 个 test shard)。绿了我入队。

@hotlong
hotlong marked this pull request as ready for review September 7, 2026 23:15
@hotlong
hotlong added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 18b6a75 Sep 7, 2026
34 checks passed
@hotlong
hotlong deleted the fix/kanban-name-field-resolver-8400 branch September 7, 2026 23:31
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.

看板卡片重复渲染记录标题:跳过集读的是框架从不发出的废弃别名 NAME_FIELD_KEY,而不是本仓自己的 resolveNameField()

1 participant