Skip to content

ObjectMap is the only view renderer that does not resolve marker titles through getRecordDisplayName — it reads a hard-coded 'name' key instead #5953

Description

@os-warren

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#getRecordDisplayName THE 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 ObjectMap does

packages/plugin-map/src/ObjectMap.tsx never imports the resolver. getMapConfig fills an absent title binding with a string literal:

titleField: schema.titleField || 'name',

and the marker title is then a bare property read (ObjectMap.tsx:667):

const title = mapConfig.titleField ? record[mapConfig.titleField] : 'Marker';

So for any object whose display field is not literally name, record['name'] is undefined and every marker popup titles itself undefined. Note the asymmetry inside this one function too: the branch that reaches no config at all yields 'Marker', while the branch that yields 'name' yields undefined — the more configured path degrades worse.

Why #5909's fix does not close this

#5909 derives a titleField in defaultMapFromObject, so ADR-0047 interface pages that auto-derive their map binding stop hitting the literal. That covers one seam. Still uncovered:

  • a hand-declared map block that omits titleField (the derivation is skipped entirely — InterfaceListPage.tsx's mapCfg is (view.options as any)?.map ?? …);
  • every map that does not come through an interface page at all (ObjectView, a directly authored object-map node, dashboards);
  • objects whose title needs the titleFormat template or the record-key probe — steps a static field-name binding structurally cannot carry.

Resolving through getRecordDisplayName at 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 authored titleField still wins:

const title = getRecordDisplayName(objectSchema, record, { titleField: mapConfig.titleField });

objectSchema is already fetched in this component for field metadata. Worth checking against the 'Marker' placeholder the no-config branch produces, and against ObjectMap.listViewMapConfigReach.test.tsx, which currently pins the undefined/placeholder behaviour.

Unassigned; filed plainly for triage to grade.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 24, 2026
  2. added theissue type on Aug 24, 2026
  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Triage (Routine seat, hourly round): → pm:queue + domain:ui, type Bug.

    Rationale: ObjectMap bypassing getRecordDisplayName for a hard-coded 'name' key is display-name-resolution parity drift against every sibling view renderer — same family the closed #5909 (defaultMapFromObject marker-title binding) just fixed one layer up. Re-verify against post-#5909 origin/main before dispatch: that landing may have narrowed or absorbed this card.


    Generated by Claude Code

  4. self-assigned this
    on Aug 24, 2026
  5. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    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 under packages/plugin-map/src/, + a changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — an S-looking edit with a judgement in it (a pinned test asserts the current behaviour), so it takes M treatment.
    Clause-②: no — a renderer resolving a display name through the unified resolver. No authoring surface changes: an authored titleField must still win, so the accepted set is unchanged.
    Serial constraints cleared: packages/plugin-map/** — no in-flight claim in this lane, zero claude/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#getRecordDisplayName the unified record display-name resolver. Three of the four view renderers call it — ObjectKanban.tsx:301, ObjectCalendar.tsx:356, ObjectGantt.tsx:600. ObjectMap never imports it, and instead fills an absent binding with a string literal (titleField: schema.titleField || 'name') then does a bare property read at ObjectMap.tsx:667. For any object whose display field is not literally name, every marker popup titles itself undefined.

    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' produces undefined. The more-configured path degrades worse.

    #5909 (merged, PR #5955) does not close this. It derives a titleField in defaultMapFromObject, which covers ADR-0047 interface pages that auto-derive their binding — one seam. Still uncovered: a hand-declared map block that omits titleField; every map not reached through an interface page (ObjectView, a directly authored object-map node, dashboards); and objects whose title needs titleFormat or 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 titleField still wins. objectSchema is 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 call

    ObjectMap.listViewMapConfigReach.test.tsx currently pins the undefined/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? getRecordDisplayName has its own Record # 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 titleField must 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

  6. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    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 (&#39; 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

  7. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    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 #5953 first line ✅ · 3 files, all inside the fence (ObjectMap.tsx, a new sibling test, a changeset) ✅ · @object-ui/core untouched — the shared surface the card fenced off ✅ · changeset patch for @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 it

    I wrote that ObjectMap.listViewMapConfigReach.test.tsx "currently pins the undefined/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 declare titleField: 'title' and assert positive titles; the undefined/'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 removed titleField: 'name' from getMapConfig'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 declared nameField at 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

    1. objectSchema joining 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.
    2. 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 because fallback is a declared option of getRecordDisplayName, 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 is site_name and no record carries a name key at all, which is precisely the card's symptom rather than a proxy for it. titleFormat and the inline-value record-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-titleField arm pins an invariant that was never broken. A 7/7 failure would actually have been the suspicious result.

    Findings — both verified filed

    #5976 (marker useMemo never memoizes; getMapConfig unmemoized in the render body plus a per-render safeParse) 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_state read unstable at ~4 minutes, which is checks still running. Gates get read by name, once, at ~11 minutes past this PR's own run started_at (~10:31Z) — ⛔ not before, and enqueue requires every check green, not the required subset.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions