Repository navigation
ObjectMap is the only view renderer that does not resolve marker titles through getRecordDisplayName — it reads a hard-coded 'name' key instead #5953
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Aug 24, 2026 Triage (Routine seat, hourly round): →
pm:queue+domain:ui, type Bug.Rationale:
ObjectMapbypassinggetRecordDisplayNamefor a hard-coded'name'key is display-name-resolution parity drift against every sibling view renderer — same family the closed #5909 (defaultMapFromObjectmarker-title binding) just fixed one layer up. Re-verify against post-#5909origin/mainbefore dispatch: that landing may have narrowed or absorbed this card.
Generated by Claude Code
Claim: PM loop round 35
Session:session_01CSoz9uGhaaSgiq3hshtN7L
Branch:claude/issue-5953-objectmap-display-name
Worktree:objectui-issue-5953
Domain:domain:ui
File surface:packages/plugin-map/src/ObjectMap.tsx+ its adjacent tests underpackages/plugin-map/src/, + a changeset (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus— anS-looking edit with a judgement in it (a pinned test asserts the current behaviour), so it takesMtreatment.
Clause-②: no — a renderer resolving a display name through the unified resolver. No authoring surface changes: an authoredtitleFieldmust still win, so the accepted set is unchanged.
Serial constraints cleared:packages/plugin-map/**— no in-flight claim in this lane, zeroclaude/issue-*branches for any open lane card. Concurrent siblings #5522 (app-shell/observability), #3917 (packages/types+content/docs), #3880 (packages/i18n/locales), #3794 (docs/adr) — all disjoint by full path.Why this one, and what the sibling card did NOT cover
ADR-0079 made
@object-ui/core#getRecordDisplayNamethe unified record display-name resolver. Three of the four view renderers call it —ObjectKanban.tsx:301,ObjectCalendar.tsx:356,ObjectGantt.tsx:600.ObjectMapnever imports it, and instead fills an absent binding with a string literal (titleField: schema.titleField || 'name') then does a bare property read atObjectMap.tsx:667. For any object whose display field is not literallyname, every marker popup titles itselfundefined.Note the asymmetry the card catches, because it is the tell that this is a defect and not a design: the branch reaching no config yields the
'Marker'placeholder, while the branch that yields'name'producesundefined. The more-configured path degrades worse.#5909 (merged, PR #5955) does not close this. It derives a
titleFieldindefaultMapFromObject, which covers ADR-0047 interface pages that auto-derive their binding — one seam. Still uncovered: a hand-declaredmapblock that omitstitleField; every map not reached through an interface page (ObjectView, a directly authoredobject-mapnode, dashboards); and objects whose title needstitleFormator the record-key probe, which a static field-name binding structurally cannot carry.PM's suggested route — measurement wins over it
The card proposes, and it looks right to me, but it is not an adjudication:
const title = getRecordDisplayName(objectSchema, record, { titleField: mapConfig.titleField });
passing the declared binding as the explicit option so an authored
titleFieldstill wins.objectSchemais reportedly already fetched in this component. Verify that before building on it — if it is not in scope at the read site, say so and report the seam rather than threading a new prop quietly.⚠️ The pinned test is the real work, and it is a judgement callObjectMap.listViewMapConfigReach.test.tsxcurrently pins theundefined/placeholder behaviour — i.e. an existing test asserts the bug. So:- ⛔ Do not simply delete or loosen that assertion to get green. Establish what the pin's subject is: is it pinning "reach" (that config arrives at all) and merely incidentally observing
undefined, or is it deliberately pinning the literal? Those need different treatment, and only the first is a safe update. - Whichever it is, say in the report which, and why, and leave the reach coverage intact.
- Resolve the
'Marker'placeholder question explicitly: after the change, what does a record with no resolvable name title itself?getRecordDisplayNamehas its ownRecord #terminal fallback, so there may now be two competing placeholders. Pick one, justify it, pin it.
Standing
⛔ This is a Bug fix, not a widening — an authored
titleFieldmust keep winning; do not take the opportunity to add new map config keys. If the honest fix turns out to need a change in@object-ui/core's resolver, stop and report: that is a shared-surface change, not this card's fence.
Generated by Claude Code
- ⛔ Do not simply delete or loosen that assertion to get green. Establish what the pin's subject is: is it pinning "reach" (that config arrives at all) and merely incidentally observing
os-dev-report
{ "issue": 5953, "status": "done", "branch": "claude/issue-5953-objectmap-display-name", "pr": "https://github.com/objectstack-ai/objectui/pull/5975", "premise_still_valid": true, "summary": "ObjectMap's marker read site now calls @object-ui/core#getRecordDisplayName, passing mapConfig.titleField as the explicit titleField option so an authored binding still wins, plus fallback:'Marker'. The 'name' literal is deleted from BOTH getMapConfig branches that carried it (the flat branch the card names, and the default branch — same literal, same defect class, declared as a bounded in-place fix in the PR body). objectSchema joined the marker useMemo's deps, since it arrives from an async fetch after first paint. ZONE 2 ASSUMPTION 1 CONFIRMED: objectSchema is in scope at the read site (component state, ObjectMap.tsx:529) — no prop threading needed; it is null for inline 'value' data, which the resolver handles via its record-key probe (pinned). ZONE 2 ASSUMPTION 2 CONFIRMED against merged PR #5955 (f09c7bc95, verified an ancestor of my base): defaultMapFromObject runs only when (view.options)?.map is absent and only inside InterfaceListPage, and displayFieldOfObject returns a static field name, so it structurally cannot carry titleFormat or the record-key probe. ZONE 3 PREMISE FALSIFIED: ObjectMap.listViewMapConfigReach.test.tsx does NOT pin the undefined/placeholder behaviour anywhere. All six arms declare titleField:'title' and assert positive titles; the undefined/'Marker' mention is prose in a comment at line 138, not an assertion. A repo-wide grep for a 'Marker' string assertion returns only that comment and the source line. The pin's subject is REACH, not the literal, so there was nothing to delete or loosen — the file is untouched and green. Placeholder question resolved: id-bearing records now read 'Record #<id>' (it names one record; 'Marker' describes every pin equally), and 'Marker' survives only as the resolver's declared fallback option for an id-less record, displacing its generic 'Untitled'. That needed NO change to @object-ui/core, so the shared-surface fence held.", "tests": "All gates ran on 9b93dea, clean tree. (1) `pnpm exec vitest run packages/plugin-map/ --maxWorkers=2` from the repo root -> 'Test Files 14 passed (14)' / 'Tests 83 passed (83)'. NOTE: `pnpm --filter @object-ui/plugin-map test` is REFUSED by this repo's vitest-invocation guard (objectui#3378) — from a package cwd it silently runs apps/console's 22 files and reports green. (2) New pin file ObjectMap.markerTitle.test.tsx, 7 arms, verbose: all 7 green. (3) ABLATION — reverted ONLY ObjectMap.tsx to origin/main, re-ran the pin file: 'Tests 6 failed | 1 passed (7)', the direction predicted before the run; the lone survivor is the authored-titleField-still-wins arm, correct because it pins an invariant that was never broken. Mutation confirmed on disk by anchored greps in both directions BEFORE the run (deleted-text-restored=1, injected-text-gone=0, `|| 'name'` literal back=1), restore trapped on EXIT INT TERM, tree verified byte-clean afterwards (git status empty, git diff HEAD empty). NO REBUILD on this leg and none needed: the root vitest.config.mts aliases every @object-ui/* specifier to that package's src/, so the run resolves source directly and no dist/ can go stale between legs. (4) `pnpm --filter @object-ui/plugin-map type-check` exit 0 (script name echoed — `tsc --noEmit && tsc -p tsconfig.test.json`, not a zero-match silent pass); its 14-package dependency closure was built first via `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-map^...' build`. (5) `pnpm --filter @object-ui/plugin-map lint` -> '110 problems (0 errors, 110 warnings)', all pre-existing no-explicit-any/no-unused-vars; the new test file contributes 7 in the same vi.mock idiom every sibling file uses (5-12 each), and lint.yml sets no --max-warnings. (6) check-control-bytes '✅ OK (scanned 4952 tracked text file(s))'; check-changeset-presence '✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-fixed exit 0; check-changeset-no-major '✅ No changeset declares a major bump'. (7) Blast radius: the 4 flatten-producer test files outside plugin-map that mention ObjectMap/titleField (plugin-view, plugin-list x2, app-shell) -> 'Test Files 4 passed (4)' / 'Tests 39 passed (39)'; all assert forwarded config SHAPES against spies, not rendered titles. NARROWING DECLARED: lint covered the whole plugin-map package (the unit CI's lint job covers), not just my 3 files; other packages' lint jobs left to CI. That excludes nothing — the file population came from eslint's own resolution not a hand list, the count of 21 files was read from --format json, and eslint.config.js enables NO type-aware linting (no project/projectService in languageOptions), so no rule's verdict on an untouched file can move from this diff. The repo-wide `pnpm check` farm is CI's run. All heavy commands went through the shared container lock (scripts/pm/os-verify-lock.sh); verdicts read from its VERDICT lines, exit codes captured before any pipe.", "open_questions": [], "out_of_scope_findings": [ "filed as #5976: ObjectMap's marker useMemo never memoizes — getMapConfig runs unmemoized in the render body (ObjectMap.tsx:594), so mapConfig has a fresh identity every render and the marker pipeline recomputes each time; also a per-render zod safeParse. Not caused or worsened by this card; no label (concrete defect, left for PM triage)", "filed as #5977: listViewMapConfigReach.test.tsx:138 narrates a getMapConfig literal in the present tense that no longer exists, and mis-attributes 'Marker' to getMapConfig when it was the read site's ternary else-arm ('name' was getMapConfig's). Comment-only, no coverage affected; labelled `finding`" ] }Issue-body sanitizer check: no truncation. The body arrives HTML-entity-encoded through the API (
'for apostrophes), which is API transport encoding, not sanitizer loss — every section, code fence and bullet is intact end to end.
Generated by Claude Code
Generated by Claude Code
ACCEPT — PR #5975 (R35,
domain:ui)Reviewed against the diff and
origin/main, not against the report's self-description.Checklist: draft ✅ · base
main✅ ·Fixes #5953first line ✅ · 3 files, all inside the fence (ObjectMap.tsx, a new sibling test, a changeset) ✅ ·@object-ui/coreuntouched — the shared surface the card fenced off ✅ · changesetpatchfor@object-ui/plugin-map✅ · ablation with the direction predicted before the run ✅.⚠️ Zone 3 of my dispatch was false, and you measured it rather than working around itI wrote that
ObjectMap.listViewMapConfigReach.test.tsx"currently pins theundefined/placeholder behaviour — an existing test asserts the bug", and built a whole judgement-call section on top of it. It does not. All six arms declaretitleField: 'title'and assert positive titles; theundefined/'Marker'mention is prose in a comment at line 138 with no assertion anywhere, and a repo-wide grep for a'Marker'string assertion returns only that comment and the source line.The card said it and I amplified it into a warning instead of verifying it. That is the second time this round my dispatch word carried an unverified card claim as fact (the other was #3880's dead sibling-card reference). Both are the same root cause and it is now recorded in the seat post: a claim in the card body is a claim, not a reading — the stale-premise check covers the card's assertions about the tree, not only its
Blocked-by:links.What you did with it is the right shape: established the pin's subject is reach, left the file untouched and green, and wrote the missing coverage in a new file rather than bending an existing one. Had I been right, deleting an assertion would have been the trap; being wrong, the risk was writing coverage that duplicated it. Neither happened.
The scope extension — approved, and it was correctly declared rather than slipped in
The card names only the flat-branch
|| 'name'; you also removedtitleField: 'name'fromgetMapConfig's default branch. Inside the file fence, same literal, same function, same defect class, and the reasoning holds: a forged'name'binding is not inert, it outranks the object's own declarednameFieldat step 0 of the resolver, so leaving the sibling would have kept the bug alive in a narrower form. Declaring it in the PR body as a bounded in-place fix is what makes it reviewable instead of a silent widening.The line you drew around it is the part worth keeping: the coordinate guesses (
latitude/longitude/location) stay, because nothing but this component can read a location out of an unconfigured record — whereas a title is exactly what the resolver reads better. That is a reason, not a preference.Two things I did not ask for and that make this correct rather than merely done
objectSchemajoining the memo's dependency array. It lands from an async fetch after first paint and the titles are now resolved from it — omitting it would have left first-painted markers titled from a null object definition permanently. That is a real bug the fix would otherwise have introduced, and nothing in the card or my dispatch pointed at it.- The placeholder resolution is argued, not picked.
Record #<id>for an id-bearing record,'Marker'only where there is no id — split on how much either can actually say, since every pin on a map is a marker and the word separates none of them. And becausefallbackis a declared option ofgetRecordDisplayName, resolving it needed no core change.
The pin
Seven arms, and they are pins rather than smoke tests because each carries a negative assertion —
not.toContain('undefined'),not.toContain('Marker'),not.toContain('Untitled'). The discriminating arm is built right: the object's display field issite_nameand no record carries anamekey at all, which is precisely the card's symptom rather than a proxy for it.titleFormatand the inline-valuerecord-key probe cover the two steps a static field-name binding structurally cannot carry — the reason #5909/PR#5955 could not have closed this.Ablation reads 6 failed | 1 passed, and the survivor is explained rather than waved at: the authored-
titleFieldarm pins an invariant that was never broken. A 7/7 failure would actually have been the suspicious result.Findings — both verified filed
#5976 (marker
useMemonever memoizes;getMapConfigunmemoized in the render body plus a per-rendersafeParse) and #5977 (the stale line-138 comment). Correctly separated: #5976 is a concrete defect this card neither caused nor worsened, and folding a perf refactor into a title-resolution fix would have widened the diff past its subject. Both are sweep-visible and left for triage to grade.Landing
Not governed.
mergeable_statereadunstableat ~4 minutes, which is checks still running. Gates get read by name, once, at ~11 minutes past this PR's own runstarted_at(~10:31Z) — ⛔ not before, and enqueue requires every check green, not the required subset.
Generated by Claude Code
Measured while implementing #5909 (PR pending). That card is fixed at the interface-page seam; this is the general half it does not reach.
What the sibling renderers do
ADR-0079 made
@object-ui/core#getRecordDisplayNameTHE unified record display-name resolver, and the view renderers call it:packages/plugin-kanban/src/ObjectKanban.tsx:301—getRecordDisplayName(objectDef, item)packages/plugin-calendar/src/ObjectCalendar.tsx:356—getRecordDisplayName(objectSchema, record)packages/plugin-gantt/src/ObjectGantt.tsx:600—getRecordDisplayName(objectSchema, record)Each takes an explicitly declared title field first and otherwise walks the object-level precedence (
nameField→displayNameField/NAME_FIELD_KEY→titleFormat→ type-aware field derivation →Record #<id>).What
ObjectMapdoespackages/plugin-map/src/ObjectMap.tsxnever imports the resolver.getMapConfigfills an absent title binding with a string literal:and the marker title is then a bare property read (
ObjectMap.tsx:667):So for any object whose display field is not literally
name,record['name']isundefinedand every marker popup titles itselfundefined. Note the asymmetry inside this one function too: the branch that reaches no config at all yields'Marker', while the branch that yields'name'yieldsundefined— the more configured path degrades worse.Why #5909's fix does not close this
#5909 derives a
titleFieldindefaultMapFromObject, so ADR-0047 interface pages that auto-derive their map binding stop hitting the literal. That covers one seam. Still uncovered:mapblock that omitstitleField(the derivation is skipped entirely —InterfaceListPage.tsx'smapCfgis(view.options as any)?.map ?? …);ObjectView, a directly authoredobject-mapnode, dashboards);titleFormattemplate or the record-key probe — steps a static field-name binding structurally cannot carry.Resolving through
getRecordDisplayNameat the read site would cover all of them and delete the literal, which is the shape the other three renderers already have.Suggested shape
In the marker
useMemo, replace the bare read with the resolver, passing the declared binding as the explicit option so an authoredtitleFieldstill wins:objectSchemais already fetched in this component for field metadata. Worth checking against the'Marker'placeholder the no-config branch produces, and againstObjectMap.listViewMapConfigReach.test.tsx, which currently pins theundefined/placeholder behaviour.Unassigned; filed plainly for triage to grade.