fix(plugin-detail): an interpolated titleFormat H1 hides no field row (objectui#8351, ruled option B) - #9437
Conversation
objectui#8351, maintainer ruling 2026-09-08 (decision batch #82), option B. `record:details`' dedupe ladder answers "which single field is the H1 showing". A `titleFormat` rung answers with a rendered TEMPLATE, which on a multi-field format is no field's value at all — so the ladder walked past it onto `resolveNameField` and hid THAT row, a row the H1 never showed. Nothing errored; a field was simply absent from the grid. "Fully interpolates" is measured with the instruments already in the chain, not a new predicate: `formatTitleTemplate` (what `getRecordDisplayName` step 3 and this package's own `DetailView.resolveDisplayTitle` step 2 both call) renders the title, and `recordDisplayValueAt` — the emptiness authority the rest of the ladder already imports — answers whether that string IS some candidate's value. Three outcomes: - composite, no candidate's value equals it -> hide NOTHING (the ruled case); - renders nothing on this record -> the header has already walked past this rung, so the existing value-keyed walk runs unchanged; - collapses onto ONE field's value (a blank placeholder dropped with its orphan separator, or a single-field format) -> that row IS the duplicate and still goes. The match is a SCAN of the candidates rather than a peek at the first one with a value: with `titleFormat: '{name}'` over `nameField: 'contract_no'` the first resolving candidate is `contract_no` while the H1 is `name`'s value, so stopping early hides the wrong row and leaves the real duplicate. Deliberately untouched, by the same ruling: `page:header`'s own `schema.title` (invisible to this package), and the ORDER in which `PageHeaderRenderer` ranks `titleFormat` against the ADR-0079 pointer — that moves what the H1 shows on existing records and retires a green pin, so it carries its own `needs-user-decision` card. This ladder does not depend on which of the two wins the header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
|
| Step | Outcome |
|---|---|
| Build packages | success |
| Check console performance budget | failure |
Which half objected:
| Eager-closure half | Verdict |
|---|---|
| Aggregate closure ceiling | ✅ pass |
| Per-chunk ceilings | ✅ pass |
| Ceiling sensitivity (headroom) | |
| Ceiling freshness (checkout vs. base branch) | ✅ pass |
⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. TheCheck console performance budgetstep log carries the ceiling and the number it was compared against.
Reason: The entry chunk measured 144.5 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.
See the workflow run for details.
📦 Bundle Size Report
| Package | Size | Gzipped |
|---|---|---|
| app-shell (consoleActionDispatch.js) | 0.20KB | 0.19KB |
| app-shell (index.js) | 16.69KB | 6.21KB |
| 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) | 502.94KB | 115.47KB |
| core (index.js) | 8.52KB | 3.41KB |
| create-plugin (index.js) | 27.94KB | 9.51KB |
| data-objectstack (index.js) | 213.54KB | 59.33KB |
| fields (index.js) | 247.89KB | 62.50KB |
| 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) | 8.87KB | 3.64KB |
| i18n (index.js) | 5.22KB | 2.26KB |
| i18n (pickLocalized.js) | 9.86KB | 3.95KB |
| i18n (provider.js) | 32.15KB | 10.49KB |
| 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.83KB | 10.95KB |
| 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) | 13.52KB | 4.88KB |
| 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) | 8.39KB | 3.10KB |
| 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) | 14.81KB | 3.63KB |
| plugin-calendar (index.js) | 49.92KB | 14.22KB |
| plugin-charts (index.js) | 71.33KB | 19.90KB |
| plugin-chatbot (index.js) | 195.34KB | 46.51KB |
| plugin-dashboard (index.js) | 131.44KB | 34.65KB |
| plugin-designer (index.js) | 215.94KB | 44.33KB |
| plugin-detail (index.js) | 252.27KB | 65.55KB |
| plugin-editor (index.js) | 2.23KB | 1.05KB |
| plugin-form (index.js) | 136.71KB | 34.16KB |
| plugin-gantt (index.js) | 167.62KB | 41.26KB |
| plugin-grid (index.js) | 212.55KB | 57.83KB |
| plugin-kanban (index.js) | 46.63KB | 14.53KB |
| plugin-list (index.js) | 112.53KB | 27.63KB |
| plugin-map (index.js) | 21.48KB | 6.99KB |
| plugin-markdown (index.js) | 13.88KB | 4.80KB |
| plugin-report (index.js) | 43.41KB | 11.93KB |
| plugin-timeline (index.js) | 30.07KB | 8.74KB |
| plugin-tree (index.js) | 10.58KB | 3.72KB |
| plugin-view (index.js) | 84.36KB | 20.78KB |
| 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) | 96.00KB | 31.71KB |
| react (data-invalidation.js) | 5.05KB | 2.08KB |
| react (index.js) | 4.63KB | 2.18KB |
| react (schema-input.js) | 4.25KB | 2.04KB |
| 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.66KB | 2.50KB |
| sdui-parser (input-type.js) | 2.84KB | 1.40KB |
| sdui-parser (kanban-quick-add.js) | 3.89KB | 1.87KB |
| sdui-parser (parse.js) | 25.28KB | 7.80KB |
| sdui-parser (provenance.js) | 3.66KB | 1.82KB |
| sdui-parser (types.js) | 0.28KB | 0.23KB |
| sdui-parser (validate.js) | 14.82KB | 4.99KB |
| 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 (strict-authoring-face.js) | 14.04KB | 5.36KB |
| 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
|
Fixes #8351
Implements the ruled option B (director seat, decision batch #82, 2026-09-08, maintainer 「同意」): in
plugin-detail, an interpolatedtitleFormatH1 names no single field, so the dedupe ladder hides no row.ZONE 2 measurements — both assumptions checked before any edit
1. Did
plugin-detailmove under objectui#9299? For this card's surface, no. objectui#9299 landed as7098eed(this branch's base). Itsplugin-detaildiff extractedRecordDetailDrawer's payload intoRecordDetailPaneland mounted it throughNavigationOverlay— the record overlay shell. The dedupe ladder was not in that diff:git log -- packages/plugin-detail/src/renderers/record-details.tsxstops at345e24a, two cards earlier. And the extraction did not relocate the H1 —RecordDetailPanel.tsxhas zero occurrences ofh1,title,DisplayName,page:headerorrecord:details. The ladder is where the card says it is, inpackages/plugin-detail/src/renderers/record-details.tsx, and the H1 is still drawn a package away by@object-ui/components'PageHeaderRenderer.2. What "fully interpolates" is measured against — established from the code, and it is NOT a bare presence test.
The chain already owns both instruments, so no new predicate was written:
formatTitleTemplate(packages/core/src/utils/record-title.ts) renders the template. It is whatgetRecordDisplayNamestep 3 calls and what this package's ownDetailView.resolveDisplayTitlestep 2 calls, so all three agree about what atitleFormatproduces on a given record.recordDisplayValueAt— the emptiness authority objectui#8350 made this ladder import rather than re-spell — then answers the only question a dedupe has: is that rendered string some candidate's value?Three outcomes, and the two that are not the headline case are what keep the rule honest:
titleFormatmerely resolves printsContract No: HT-2026-003directly beneath an H1 readingHT-2026-003, on every record where a blank placeholder was dropped with its orphan separator — which is precisely the duplication Phase P.0 of this ladder exists to remove. It would also make the lit control this PR was asked for unconstructible: the suggested route's "atitleFormatthat does not fully interpolate, where the ladder must still hide" only exists under a reading where partial interpolation still hides.titleFormat: '{name}'overnameField: 'contract_no', the first resolving candidate iscontract_nowhile the H1 isname's value — stopping early hides the wrong row and leaves the real duplicate, the objectui#8175 shape one rung along. Pinned.Red / green legs
The pin is lit, measured twice, in both directions:
7098eed):Test Files 1 failed,Tests 2 failed | 5 passed (7). The two reds areHALF 1 — KEEPS the declared nameField rowandhides the row the TEMPLATE names….425cd85):Test Files 1 passed (1),Tests 7 passed (7).trap-guarded restore: the rung's read site was neutralised (const interpolatedTitle = ''), anchor occurrences1 → 0, injected marker count1, on-disk hash differs from theHEADblob — thenTests 2 failed | 5 passed (7), the same two cases. Restore verified two ways, not by an exit code:git diff HEADempty andgit hash-objectequal to theHEADblob. Nodist/is on the path — the pin imports../record-detailsrelatively, from source in the same package — so no rebuild leg was owed.The remaining five cases include three deliberate controls that pass on both sides (an object with no
titleFormatdedupes exactly as before; a template resolving to nothing leaves the ladder alone; a single-field{name}template still dedupes) — without them, a change that simply disabled the H1 dedupe outright would pass everything.Gates run locally
All at
425cd85; the working tree is byte-identical to it (git diff HEADempty).pnpm exec vitest run packages/plugin-detail/— 176 files, 1632 tests, all passed (under the sharedos-verify-lock.sh).pnpm --filter @object-ui/plugin-detail run type-check— exit 0 (tsc --noEmit && tsc -p tsconfig.test.json, so the new pin is type-checked too).pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' buildfirst: without the dependencydist/*.d.tsit reportsTS2307 Cannot find modulefor every workspace import — a missing prerequisite, not a red gate.pnpm exec eslint packages/plugin-detail --format json— 233 files, 0 errors, 1013 warnings (all pre-existingno-explicit-any/react-refresh, the same shape the neighbouring pins carry). Declared narrowing against the repo-wideLintjob: the population is eslint's own file selection for that directory, not a glob of mine; the count is read from--format json; andeslint.config.jsenables no type-aware linting (noparserOptions.project, noprojectService), so no rule crosses a file boundary and this diff cannot move the verdict on any file it does not itself contain. The repo-wide run is CI's.node scripts/check-changeset-presence.mjs— exit 0,1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s).node scripts/check-changeset-no-major.mjs— exit 0.check:control-bytes·check:test-path-roots·check:changeset-claims·check:unreferenced-sources·check:new-line-citations·check:comment-mask-corpus·check:vi-mock-specifiers— all exit 0. Plus a direct control-byte scan of the three changed files (grep -naPover the C0 range): no hits.node scripts/check-governed-queue-guard.mjs --test—NOT GOVERNEDfor all three paths.NOT MEASURED:
check:sdui-registration-pins—❌ No console build to weigh at apps/console/dist/assets. A missing prerequisite (it weighs anapps/consolebuild this worktree has never produced), not a verdict on this diff. Left to CI.Not run locally, by design: the repo-wide
Lint/Type Check/ the fourTestshards /Build & E2E/Build Docs. Those are the farm's; CI convergence is the PM's read, not a local one.Acceptance notes
Out of scope, observed while measuring, noted and not filed:
@object-ui/components'PageHeaderRendererand@object-ui/core'sformatTitleTemplateare two different interpolators for the sametitleFormat. The header's localinterpolatematches{[a-zA-Z0-9_.]+}and then refuses the result if a{survived;formatTitleTemplatematches{[^{}]+}, normalises handlebars{{x}}first, and strips orphan separators. So a token such as a name containing a space is a value to one and a literal to the other, and the two halves can disagree about whether the template rung resolved at all. Successor: objectui#9436, whose whole subject is that code region and which must weigh both interpolators whichever way it is ruled — this is not a card of its own, it is context that card needs.DetailView.resolveDisplayTitleis a third ranking of the same rungs, inside this package (primaryField→titleFormat→ unified resolver →schema.title). It readsschema.primaryFieldoff the view schema, which is legitimate and explicitly protected by objectui#7586, so this is an observation about how many orderings exist, not a defect. Successor: objectui#9436 — the same ruling decides whether three orderings collapse to one.Neither is filed separately: both are the same ordering question that card asks, and a card whose answer is "see objectui#9436" is noise in the queue.
Option C — filed, not folded in
The ruling placed an obligation on whoever took this card. objectui#9436 is now open, carrying
needs-user-decision/domain:ui/priority:p2. It states plainly that the change moves what the H1 shows on existing records, and it names the currently-green pin it would retire:packages/components/src/__tests__/page-header-title.test.tsx, casetitleFormat still outranks nameField.⛔ Nothing in this PR touches
@object-ui/components, that pin, or the ordering. This ladder deliberately does not depend on which rung wins the header: it compares the rendered title against the candidates' values, which answers the dedupe under either order — so objectui#9436 can be ruled either way without reopening this.⛔ Also untouched, excluded by the ruling by name:
page:header's ownschema.title. It is a key on the header schema, whichrecord:detailsnever receives, so nothing in this package can answer it.Dedup before filing:
/search/issuesis 403 in this container, so REST list endpoints plus a local grep over all 429 open issues, all 25needs-user-decisionissues and PRs (state=all), this card's cross-reference timeline, and 1902 closed issues — 38 cards matched the six keywords, all read, none asking the precedence question. Two control words fired on objectui#8351 in the same pass (titleFormat still outranks nameField→ 1,PageHeaderRenderer ranks→ 1), so the zero is a reading rather than a dead channel.Files changed
packages/plugin-detail/src/renderers/record-details.tsx— the rung, plus the ladder docblock, which had recorded both these divergences as "deliberately NOT repaired here"; one of the two now is, and the comment says which and why the other cannot be.packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx— new pin, 7 cases..changeset/8351-titleformat-hides-no-row.md—patchon@object-ui/plugin-detail; user-visible, so a real bump rather than an empty declaration.🤖 Generated with Claude Code
https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
Generated by Claude Code