Repository navigation
fix(core,components): one titleFormat interpolator for the record title (#10447, #10446) - #10555
Conversation
…ordDisplayValueAt does (#10446) A whitespace-only placeholder value counted as resolved, so `{contract_no} - {name}` with a blank `name` rendered `HT-2026-003 -` in `formatTitleTemplate` and in `getRecordDisplayName`'s titleFormat rung. The value half of `recordDisplayValueAt` (trim, then empty; an expanded reference reduced to its display name) is now a module-private `displayValueOf`, and each placeholder goes through it. One implementation of the emptiness rule instead of two spellings of it. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude <noreply@anthropic.com>
…h core's formatTitleTemplate (#10447) `page:header` rendered the legacy template with its own `interpolate` plus `cleanupTitleSeparators`, while `getRecordDisplayName`, `DetailView` and `record:details`' H1 dedupe call core's `formatTitleTemplate`. The two had different token rules: an expanded lookup token rendered as nothing in the H1 and as its display name in core, so `{account} - {deal_no}` read `Q3-042` in the H1 while the dedupe compared `Acme - Q3-042` and left the duplicate "Deal No" row printed under the heading. The rung now hands the declared template to `formatTitleTemplate` and takes its answer, including its empty "nothing resolved". The H1's translated option label for a select token is kept by mapping select values to labels on a copy of the record (`withOptionLabels`) before core renders it, so the formatter's signature does not move; the label lookup is one helper (`optionLabelFor`) shared with `interpolate`. `cleanupTitleSeparators` had no other caller and goes with its test. `interpolate` keeps its two other callers, the header's own `title` and `subtitle`. The rungs' order is unchanged. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude <noreply@anthropic.com>
…n build narrows it (#10447) Spreading an `any` record into the `Record | undefined` copy re-widened it to the declared type, and the declaration build refused `copy[key] = label` with TS18048. The source record is now typed once. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
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
Size Limits
|
…ung's one interpolator (#10447) `.changeset/9436-page-header-declared-pointer-order.md` (item 3) and `.changeset/9174-interpolate-fastpath-trim.md` both publish in the same release and described the record-title `titleFormat` rung as rendered by `page:header`'s own `interpolate()`. Each sentence is scoped to "at this change" and followed by a dated note: the rung now renders through core's `formatTitleTemplate`, with select option labels still translated via `withOptionLabels`. Frontmatter unchanged in both (md5 of the block equal before and after). The objectui#10447 changeset names both entries. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsInputs read. Cards #10447/#10446 bodies and all 4 comments (triage #10446 — exact rule, faithful extraction. Base #10447 — H1 rung. Head rung is
Stop condition. Admitted behaviour changes. (a) no-placeholder / nothing-resolved template no longer shows literal text, walks on; (b) resolved values trimmed. Both are exactly what core already did for Pins. Read, not run. Core file: 4 + 11 cases; Pending changesets. Frontmatter md5 base = head: File surface. 10 files: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #10447
Fixes #10446
Clause-②: no — one renderer's private interpolation rebinds to the existing core authority, and core's empty test aligns with
recordDisplayValueAt. No export, schema or accept set moves.Folded pair, one branch, executed from the triage on each card (comments 5827587063 and 5827564760) and the PM claims 5828659772 / 5828664602. objectui#10446's core fix lands first, then the H1 rebinds, so no commit on this branch renders
HT-2026-003 -in the H1.Commits
4c140cb9c— objectui#10446,@object-ui/core.formatTitleTemplatejudges each placeholder through the value half ofrecordDisplayValueAt, now the module-privatedisplayValueOf: trim, then empty, with an expanded reference reduced to its display name. It used to test the raw value against null, undefined and the empty string on its own, so a whitespace-only value counted as resolved and the final whitespace collapse left its separator dangling. One implementation of the emptiness rule instead of two spellings of it. Exported signatures unchanged.ef988ac2f— objectui#10447,@object-ui/components. The record page H1'stitleFormatrung hands the declared template (string or Expression envelope) to core'sformatTitleTemplateand takes its answer, including core's empty "nothing resolved"; the rung's separate unresolved-token test is gone.cleanupTitleSeparatorshad no other caller and is deleted with its test file. The rungs' order (objectui#9436) does not move.47536ebd8— a typing fix inside the same helper: the declaration build refused a copy that re-widened to possibly-undefined (TS18048).b06b17020— prose only: two pending changesets reconciled with the rung's one interpolator (see "Reconciled pending changesets" below), and this change's changeset names both.Measured before the change: the two interpolators, token by token
Probe at base
6881e9e47: the realpage:headerH1 and the header's own rung (itsinterpolatepluscleanupTitleSeparators, plus its "contains a brace, so decline" test) against core'sformatTitleTemplate, same record. "declines" means the rung handed over to the unified resolver, which renders core's way.{account} - {deal_no},accountexpanded to AcmeQ3-042Acme - Q3-042Q3-042Acme - Q3-042{a} - {b},bwhitespace-onlyAA -AA{a}, {b},bmissingA,AA,ASession — {a},amissingSessionSessionStatic title(no placeholder)Static titleStatic title{status}, selectin_progressIn Progressin_progressIn ProgressIn Progress(kept){{a}} - {{b}}A - BA - BA - B{ a } - { b }A - BA - BA - B{first-name} {a}Ada LAda LAda L0, Expression envelope, the separators·:–, a 3-part template with its middle emptyAfter the change every row where core answers non-empty has H1 equal to core (select rows: equal to core over the labelled record). Where core answers empty, the H1 walks on to the next rung, as
getRecordDisplayNamewalks from its template rung.Select option labels (H2): kept, without touching
formatTitleTemplate's signaturefieldOptionLabel; core has no such hook.withOptionLabelsbuilds a copy of the record whose select values read as their option labels, and the rung renders core over that copy.optionLabelForis the one label lookup in the file, shared withinterpolate, so the two readers cannot drift.record:details, which renders the template over the raw record, still decides about the field the H1 shows. Pinned in plugin-detail (the Status-row cases below).git grep -n -E "titleFormat['\"]?\s*[:=]" -- ':!*.test.*' ':!**/__tests__/**' ':!**/CHANGELOG.md'. The one object declaration it finds outside tests iscontent/docs/guide/building-crud-app.md'stitleFormat: '{title}', a text field, so no in-repotitleFormatnames a select field. Control: the same pattern over test files is non-empty and includespage-header-title.test.tsx's{status}select case. First-party producers outside this repo (objectstack platform objects, e.g.sys_audit_logwith{action} · {object_name}andsys_memberwith{user_id} ({role})) do name select fields, and each of those also declares anameField, so their template rung is reached only when that pointer is blank on a record. Re-derive withgit grep -n titleFormat origin/main -- 'packages/**/src/**'in objectstack.interpolate's callers (H3)It keeps two callers, both
page:header's own templates:titleandsubtitle. They are unchanged. ThetitleFormatrung was the third and no longer comes through it, sointerpolatestays. Instrument:grep -n "interpolate(" packages/components/src/renderers/layout/containers.tsx.Who the #10446 rule reaches (H4)
formatTitleTemplateis called bygetRecordDisplayName's template rung (core),DetailView.resolveDisplayTitleand therecord:detailsH1 dedupe (plugin-detail), and nowpage:header.@object-ui/app-shellre-exports it asformatRecordTitle.getRecordDisplayName, so every resolver-backed surface: app-shellRecordDetailView; componentspage:header; coresort-values; fieldsindexandLookupField; plugin-calendarObjectCalendar; plugin-detailDetailViewandRelatedList; plugin-ganttObjectGantt; plugin-kanbanObjectKanban; plugin-listObjectGallery; plugin-mapObjectMap; plugin-treeObjectTree. Instrument:git grep -n "getRecordDisplayName(" -- 'packages/**/src/**' 'apps/**/src/**' ':!**/__tests__/**' ':!*.test.*'.record-titlesuites stay green, and every other test file that mentionstitleFormatwas run too (Gates below).Pins, red on base then green on head
New files:
packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.tspackages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsxpackages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsxRed leg, from committed head
ef988ac2f: both source files checked out at base6881e9e47, new tests kept. On-disk proof before the run:displayValueOf0 hits and the old empty test 1 hit inrecord-title.ts;withOptionLabels0 hits andcleanupTitleSeparators2 hits incontainers.tsx. ResultTests 11 failed | 25 passed (36):expected 'HT-2026-003 -' to be 'HT-2026-003'at both doors; blank leading placeholderexpected '- Acme' to be 'Acme'; agreement rows "spaces"expected '- K' to be 'K'and "no-break spaces".page:header: THE CARDexpected 'Q3-042' to be 'Acme - Q3-042'; whitespaceexpected 'HT-2026-003' to be 'HT-2026-003 -'(H1 against core); commaexpected 'Q3-042,' to be 'Q3-042'; literal prefixexpected 'Session' not to be 'Session'.page:headerbesiderecord:details: THE CARDexpected 'Q3-042' to be 'Acme - Q3-042'; finding(core):formatTitleTemplatetreats a whitespace-only placeholder value as resolved, so{contract_no} - {name}with a blank-lookingnamerendersHT-2026-003 -on every resolver-backed surface #10446 in the pageexpected 'HT-2026-003' to be 'HT-2026-003 -'.Restore leg:
git checkout HEAD --both paths inside an EXIT/INT/TERM trap, blob hashes equal toef988ac2f's (d7e714385,86a980376),git diff HEADempty.47536ebd8differs fromef988ac2fonly by the typing fix insidewithOptionLabels, a file the red leg replaces with base, so the red and green numbers carry over. Green leg: the three files pass on head inside the package runs listed under Gates; the two render files alone readTests 21 passed (21).Gates
Code, type and test gates ran at
47536ebd8; the changeset gates were re-run on the prose commitb06b17020(no code or test file moved between the two).pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-detail^..." build(the dependency closure, includes core and components): exit 0.type-checkof@object-ui/core,@object-ui/components,@object-ui/plugin-detail:Done×3, exit 0. Each package's test program lists its new test file (tsc -p tsconfig.test.json --listFiles, 1 match per package).pnpm exec vitest run packages/core/:Test Files 172 passed (172),Tests 3501 passed (3501).pnpm exec vitest run packages/components/ packages/plugin-detail/:Test Files 507 passed | 2 skipped (509),Tests 4976 passed | 25 skipped (5001).titleFormat(app-shell, fields, plugin-dashboard, plugin-grid, plugin-map, react, types):Test Files 21 passed (21),Tests 218 passed (218).pnpm --filter @object-ui/components run test:dist:Test Files 2 passed (2).check-changeset-presence(atb06b17020):✅ 7 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s): .changeset/10447-one-title-interpolator.md.check:new-line-citations(atb06b17020):VERDICT new-cross-file-line-citations: 0 new citation(s).check:control-bytes(atb06b17020):✅ check-control-bytes: OK.b06b17020:check-changeset-no-major✅ No changeset declares a major bump.;check-changeset-fixed✅ All workspace packages are in the changeset fixed group.;check:pending-changeset-literals✅ No test source names a pending changeset.;check-changeset-overwriteexit 0, report-only, listing exactly the two reconciled entries (2 modified, 0 deleted);check:changeset-claimsexit 0, report-only, and the entries it still lists name the touched files for other aspects (reviewed; none describes thetitleFormatrung's interpolation).check:test-path-roots,check-vi-mock-override-shape: exit 0.no-explicit-any/only-export-componentslevel). The package-widepnpm lintis CI's run; not measured here.Reconciled pending changesets
Two changesets still pending in the same release described the rung as rendered by the header's own interpolation. Each is reconciled in the form objectui#10533 / #10540 used, with its frontmatter byte-identical (md5 of the frontmatter block equal before and after:
3d9e2a1e59bc11924bb99ffd9eea81e3andce888ebde4729564f67bca69a7928743):.changeset/9436-page-header-declared-pointer-order.md: item 3 now reads "rendered at this change with the header's own interpolation", and "At this change, objects that declare a pointer and notitleFormat, and objects that declare atitleFormatand no pointer, render exactly as before". A dated note follows: "Dated note, 2026-09-25 — the record title's titleFormat rung now renders through core's formatTitleTemplate — objectui#10447". It states that option labels are still translated, viawithOptionLabels, and that the order is unchanged..changeset/9174-interpolate-fastpath-trim.md: "At this changepage:header'stitle/subtitle(and the record-titletitleFormat) all went throughinterpolate()". The same dated note follows, stating thattitleandsubtitlestill go throughinterpolate(), so that entry's fix holds for them unchanged..changeset/10447-one-title-interpolator.mdnames both entries.Every other pending
.changeset/*.mdwas re-grepped for present-tense claims about the header's owntitleFormatinterpolation orcleanupTitleSeparators, and none has one. Instrument:git grep -n -i -E "cleanupTitleSeparators|separator cleanup|interpolat|formatTitleTemplate|titleFormat" -- .changeset/. The nearest hits are aboutpage:header'stitle(objectui#10117, #8923) or other readers, and still hold.File surface
packages/core/src/utils/record-title.ts, and beside it the newpackages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts.packages/components/src/renderers/layout/containers.tsx, and beside it the newpage-header-title.oneInterpolator-10447.test.tsx, a docblock-only edit topage-header-title.test.tsx, and the deletedcleanup-title-separators.test.ts(its function had no other caller).packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx: new, test-only, outside the claims' listed surface ("tests beside each"). It is wherepage:headerandrecord:detailsrender together (the objectui#9436 and objectui#10360 agreement files live beside it), which the H5 pin needs. No plugin-detail source changes..changeset/10447-one-title-interpolator.md(patch on@object-ui/coreand@object-ui/components), plus the two reconciled entries above.Scope
ctx.data, the raw served row). This neither helps nor hinders that fix beyond one fact: the rung andrecord:details' dedupe now call the same function and differ only in the row they pass it.params, same file) is untouched.Acceptance notes
Session — {user_id}with no user used to readSession); the H1 walks on to the next rung.recordDisplayValueAtreturns. After core's whitespace collapse this is visible only where a padded value sits against a non-space literal, for example({code}).DetailView.resolveDisplayTitlestill renders the template over the raw record, so on the non-synthesized detail header a select token reads as its raw value wherepage:headershows the label. That predates this change and is read from source only, not measured through a running page.Generated by Claude Code