Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

bug(plugin-detail): record:details' dedupe hides the WRONG row when the object declares a nameField the literal walk does not know #8175

Closed

Description

@os-justin

Surfaced while implementing objectui#7586 (removing the primaryField rung from this same ladder). ⛔ Not caused by that change — measured as byte-identical before and after it, because the rung it removes was never populated. Filed separately rather than folded in: #7586's fence is "remove the undeclared read", and this is an ADDITION to the ladder, a different repair on the same lines.

⛔ Not claimed, not graded.

The two halves disagree

record:details drops from the body grid the one field whose value the page H1 already shows, so a record page does not print 客户名称: Acme Corporation directly under an H1 reading Acme Corporation. It picks that field with a literal walk:

packages/plugin-detail/src/renderers/record-details.tsx
  const titleCandidates = ['name', 'full_name', 'title', 'subject', 'display_name', 'label'];
  // first candidate whose value on the record is non-empty wins

The H1 is drawn a package away, by PageHeaderRenderer in packages/components/src/renderers/layout/containers.tsx, whose chain runs schema.title then titleFormat then the unified ADR-0079 resolver (nameField then displayNameField then type-aware derivation) and only THEN the same literal walk.

The resolver rung is the one the ladder has no equivalent of. So for an object whose declared nameField points at a field the literal walk does not know:

H1 shows grid hides grid still shows
nameField: 'contract_no', record has contract_no and name contract_no's value name — a row the H1 never showed contract_no — the row the H1 IS showing

Both halves of the dedupe are wrong at once: the duplicate survives, and an ordinary field disappears instead.

Measured, not inferred

The H1 half is already pinned green on origin/main — packages/components/src/__tests__/page-header-title.test.tsx, the case nameField wins over a record-level 'name' value, asserts the heading is HT-2026-001 for a record carrying name: 'internal-name'. The ladder half is a six-entry literal list with no resolver call in it; name is its first entry and the record has a value there.

Suggested shape

Ask @object-ui/core's resolveNameField first, then fall through to the literal walk — the same delegation objectui#7287 / PR #7585 made in resolveTitleField and objectui#7586 made in the header chain. Then the ladder's docstring claim (it mirrors the tail of the chip's chain) becomes true of the whole chain rather than its tail.

Whoever takes it: the pin must assert the DEDUPE outcome, which row the grid renders and which it drops, not the title. A title-only pin passes while this stays wrong — the two are decided in different packages. packages/plugin-detail/src/renderers/__tests__/record-details.primaryFieldRetired-7586.test.tsx is the harness shape for that.

Related: objectui#7586 (the removal that put these lines under a microscope) · objectui#7287 / PR #7585 · ADR-0079.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 6, 2026
  2. self-assigned this
    on Sep 7, 2026
  3. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    Claim: session session_01YBWFb5YgMU5dw8p2VKj16S · branch claude/issue-8175-record-details-namefield-dedupe

    PM dispatch (domain:ui seat). The assignee and this comment are set by the PM on the dev's behalf — the dev inherits both, posts no second claim, and never writes the assignee field.

    Dispatch note: the card's "suggested shape" (delegate to @object-ui/core's resolveNameField first, then fall through to the literal walk) is a suggestion to measure, not a ruling. If the measurement says the delegation belongs at a different rung, or that the ladder and the header chain disagree somewhere else too, that is the finding — report it rather than forcing the suggested shape.

    The card's own instruction is binding and is the part most likely to be got wrong: the pin must assert the DEDUPE outcome — which row the grid renders and which it drops — not the title. A title-only pin passes green while this stays broken, because the two halves are decided in different packages.


    Generated by Claude Code

  4. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 8175,
      "status": "done",
      "branch": "claude/issue-8175-record-details-namefield-dedupe",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8349",
      "premise_still_valid": true,
      "summary": "The card's premise held in full and was re-measured on origin/main dacb98f3b before any code was written: with nameField 'contract_no' and a record carrying both contract_no and an ordinary name, the grid rendered contract_no (the H1 duplicate survived) AND dropped name (an ordinary field vanished) at the same time, with a control row proving the grid rendered at all. record:details' dedupe ladder now leads with @object-ui/core's resolveNameField and deriveTitleField, keeping the six-entry literal walk as its unchanged tail. CORRECTION AGAINST THE BRIEF: the card's suggested shape (one resolveNameField call, then fall through to the literal walk) is NOT sufficient, and the delegation is spelled as TWO candidates. resolveNameField returns one answer and short-circuits (a declared pointer wins outright and deriveTitleField never runs), while this ladder is value-keyed and so is the header's: getRecordDisplayName re-tries the next rung when valueAt comes back empty, so a declared-but-blank pointer must fall through to the type-aware derivation. Measured: resolveNameField on a nameField:'contract_no' object whose record has no contract_no still answers contract_no, while deriveTitleField answers activity_name, which is the rung the H1 actually lands on. A single-call delegation would have left that record's grid repeating its own H1.",
      "resolveNameField_answers": "Quoted from packages/core/src/utils/record-title.ts: 'WHICH FIELD titles this object - the name-space twin of getRecordDisplayName, which answers what that field SAYS on one record. Same ladder, minus the rungs that only exist per record: 1+2. the declared pointer (declaredNameField); 4. else the type-aware derivation (deriveTitleField).' Its body is: 'const declared = declaredNameField(objectDef); if (typeof declared === string && declared.length ) return declared; return deriveTitleField(objectDef);' and declaredNameField is 'objectDef?.nameField ?? objectDef?.displayNameField ?? objectDef?.NAME_FIELD_KEY'. DELEGATED RUNGS: both of them, unrolled - resolveNameField(objSchema) for rungs 1+2 (nameField, displayNameField, NAME_FIELD_KEY) and deriveTitleField(objSchema) for rung 4 (type-aware derivation), each value-keyed against the record, then the unchanged six-entry literal walk as the tail. options.titleField (step 0, view-scoped) and titleFormat (step 3, render-only, names no field) are deliberately absent from resolveNameField and stay absent here.",
      "dedupe_pin": "packages/plugin-detail/src/renderers/__tests__/record-details.nameFieldDedupe-8175.test.tsx, shaped after record-details.primaryFieldRetired-7586.test.tsx. NOT one title assertion anywhere. WHICH ROW DROPS: contract_no (case 'HALF 1 - DROPS the declared nameField row', asserting queryByText('HT-2026-001') is null - HT-2026-001 is exactly what the H1 shows, pinned green already in @object-ui/components page-header-title.test.tsx). WHICH ROW RENDERS: name (case 'HALF 2 - KEEPS the ordinary name row', asserting getByText('internal-name') is in the document). The two halves are SEPARATE cases on purpose: inside one it() the first failure short-circuits the second, so the ablation would have reddened the duplicate half and said nothing about the vanishing half. CONTROL PROVING THE GRID RENDERED AT ALL: every case asserts a surviving ordinary row by value (getByText('42') / '7' / '11' / '5' / '3' / '99'), plus the row label 'amount' in half 1 - without it, 'queryByText is null' is trivially true on a document that rendered nothing. Second control: 'CONTROL: an undeclared object still dedupes via the literal walk' goes red for a ladder that resolves nothing at all. Third control: 'falls through to the literal walk when the declared pointer is EMPTY on the record' pins that the value-keying was preserved.",
      "grid_row_keying_measured": "objectui#8269's trap, measured rather than assumed. The dedupe filters entries through columnIdentity, which is CANONICAL-first: measured columnIdentity({field:'a',name:'b'}) = 'a'. DetailSection renders each row from field.name, i.e. 'b' for that same entry. So resolver-answer and row-column coincide only when no alias is written. Every fixture in the pin uses BARE STRING field entries, where columnIdentity(entry) === entry === the normalised name, so the pin cannot be green because a resolver answer accidentally matched a differently-keyed column. The conflicting-entry shape is a pre-existing defect class hasConflictingColumnIdentity already names and is NOT asserted or touched here.",
      "tests": "PRE-FIX MEASUREMENT (origin/main dacb98f3b, scratch probe, both halves at once): 'MEASURE grid-renders-contract_no(H1 duplicate survives): true' / 'MEASURE grid-drops-name(ordinary field vanished): true' / 'MEASURE control-row-amount-present: true' / 'MEASURE H1 value the header resolves: HT-2026-001'. The new pin run unmodified against origin/main source: 'Tests  4 failed | 2 passed (6)'. ABLATION OF THE READ SITE (not the pin), run from the committed implementation, restore proven BY STATE, trap on EXIT INT TERM with absolute paths: HEAD blob e6c1d21871bfa0f213d5da25465286012f31a822, mutated on-disk hash 3cdf19d67cd0734c5dd043f39010e8a23bea100c (differs, so the mutation reached disk), anchor counts after mutation resolveNameField(objSchema)=0 deriveTitleField(objSchema)=0 bare-literal-walk-one-liner=1, restored hash e6c1d21871bfa0f213d5da25465286012f31a822 == HEAD blob AND 'git diff HEAD --stat' 0 lines. NAMED RED ROWS, every one a DEDUPE assertion (queryByText(...).toBeNull() / getByText(...)), zero title assertions: 'HALF 1 - DROPS the declared nameField row (the duplicate must not survive)', 'HALF 2 - KEEPS the ordinary name row (no field may vanish in its place)', 'honours the deprecated displayNameField alias the same way', 'uses the type-aware derivation rung when nothing is declared', 're-tries the derivation rung when a DECLARED pointer is blank on the record'; verdict 'Tests  5 failed | 2 passed (7)'. The 2 that stayed green are the two controls that MUST stay green (they describe behaviour this change does not alter), which is what makes the five reds a measurement of the new rungs rather than of the ladder's absence. No dist is involved: the root vitest config aliases @object-ui/core to packages/core/src and the pin imports ../record-details by relative path. GREEN RUNS, all at b0a4df615 through the container's shared heavy-verify lock, verdicts quoted from the runner: packages/plugin-detail/ 'Test Files  135 passed (135)' 'Tests  1216 passed (1216)'; apps/console/ 'Test Files  89 passed (89)' 'Tests  1069 passed (1069)'; packages/app-shell/ shard 1/3 'Test Files  216 passed (216)' 'Tests  2094 passed | 1 skipped (2095)', shard 2/3 'Test Files  216 passed (216)' 'Tests  1921 passed (1921)', shard 3/3 'Test Files  215 passed (215)' 'Tests  2219 passed (2219)'; calendar+gantt+kanban+runner+schema-catalog+console-starter 'Test Files  153 passed (153)' 'Tests  2958 passed (2958)'. Package set chosen from TURBO_SCM_BASE=dacb98f3b turbo ls --affected (11 packages; examples/byo-backend-console and apps/site carry no test files). type-check: 'pnpm --filter @object-ui/plugin-detail run type-check' exit 0, echoing 'tsc --noEmit && tsc -p tsconfig.test.json'; confirmed NOT vacuous - 'tsc -p tsconfig.test.json --listFiles | grep -c nameFieldDedupe-8175' = 1, so the new pin is a real program input. Lint: package-scoped 'eslint .' in packages/plugin-detail exit 0, '899 problems (0 errors, 899 warnings)', all pre-existing no-explicit-any; the two on the new pin are the identical renderBody(record: any, schema: any) helper shape the #7586 harness already ships. --no-inline-config was NOT passed (objectstack convention, manufactures errors here). Gates: check:control-bytes 'OK (scanned 6628 tracked text file(s))'; check:phantom-deps, check:unused-deps, check:self-import, check:unreferenced-sources, check:vi-mock-specifiers, check:vi-mock-inherit all green; check-changeset-presence '4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major 'No changeset declares a major bump'; check-governed-queue-guard --test 'NOT GOVERNED - 5 path(s) checked against 5 governed surface(s); none matched'. Zero-hit control-character grep run with a LIT CONTROL on the same instrument (a synthetic BEL line fired; the four changed files did not). DECLARED NARROWINGS: check:sdui-registration-pins returned its own PREREQUISITE-NOT-MET exit 2 ('build the console first') - read as NOT MEASURED, not as a red; this diff changes no ComponentRegistry.register call. Repo-wide 'pnpm lint' and the rest of the check:* farm are CI's run, not narrowed silently.",
      "mcp_calls": "3 - create_pull_request, pull_request_read (PR body read back to the tail, intact), add_issue_comment. Everything else was zero-quota: REST was probed once and refused (403 'GitHub access is not enabled for this session'), so the issue body and its complete timeline came from the public-repo page payload channel, which reported frontTimelineItems pageInfo hasNextPage:false - a complete read, not a truncated one. The newest Claim: comment names session_01YBWFb5YgMU5dw8p2VKj16S and this exact branch; no second claim was posted and the assignee field was never written. git ls-remote for 'refs/heads/claude/issue-8175-*' returned empty before the branch was created, so there was no sibling or orphan branch on this card.",
      "open_questions": [
        {
          "question": "The ladder still disagrees with the header chain in two places, BOTH found while measuring this card. Neither names a FIELD, so neither has a row to hide - closing either changes what a user sees and is a product ruling, not a repair, so nothing was folded in. (a) page:header's own schema.title: when an author sets it, the H1 is an arbitrary interpolated string and the ladder still hides a row that no heading is showing; this package cannot see the header schema at all. (b) objectSchema.titleFormat: the header ranks the interpolated template ABOVE the declared pointer - pinned in @object-ui/components __tests__/page-header-title.test.tsx as 'titleFormat still outranks nameField' - and that ordering is itself a divergence from getRecordDisplayName, whose documented precedence puts titleFormat BELOW the declared pointer at step 3. On a titleFormat object the ladder now hides the resolver's field where it used to hide the literal walk's; neither matches the H1.",
          "options": [
            "A. Leave both. They are rungs that name no field; a dedupe keyed on 'which single field is the H1' cannot answer them, and the current behaviour is no worse than before.",
            "B. Suppress the dedupe entirely when objectSchema.titleFormat is present and fully interpolates - hide nothing, because the H1 is not any one field's value. Cheap here; does not touch schema.title, which stays invisible to this package.",
            "C. Close the titleFormat/nameField ordering disagreement at its source in @object-ui/components, so the header stops ranking the legacy template above the ADR-0079 pointer. This changes what the H1 shows and contradicts a currently-green pin, so it is squarely a maintainer ruling."
          ],
          "recommendation": "A for now, with B filed as its own card if record pages with a titleFormat are common enough to matter. C is a real ADR-0079 question but it changes the H1, which the dispatch put explicitly out of a dev's hands."
        }
      ],
      "out_of_scope_findings": [
        "NOT FILED - reported here per the dispatch's explicit 'report; do not fix without a ruling' instruction, and because REST is 403 for this session so filing would burn scarce MCP search-plus-write calls on findings the PM asked to receive as a report. Each is stated completely enough for the PM to file without re-measuring.",
        "(1) columnIdentity vs DetailSection row keying in record-details.tsx: dropHidden filters entries by columnIdentity (canonical-first, field beats name/fieldName - measured columnIdentity({field:'a',name:'b'}) = 'a') while DetailSection renders and reads the row from field.name ('b' for that same entry). For a conflicting entry the dedupe therefore decides about one field and the grid shows another. Pre-existing, unchanged by this PR, and already named as a class by hasConflictingColumnIdentity; the new pin deliberately uses bare-string entries so it does not depend on the two agreeing.",
        "(2) Emptiness asymmetry between the two halves: the header's valueAt trims strings, so a whitespace-only value counts as empty and the H1 keeps walking, while this ladder's non-empty test is a raw undefined/null/'' check. A record whose title field holds only spaces therefore loses a grid row to a heading that never showed it. Pre-existing for all six literal entries and not changed here; a one-line fix if ruled in.",
        "(3) Two fixtures were written around the old six-name ladder and had to be updated, not re-verdicted: packages/plugin-detail/src/__tests__/recordDetailsBodySource.test.tsx and packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx. Both declare an object with no name-ish field, so the resolver's derivation ends in 'first title-eligible field by declaration order' - phone and industry respectively - and their asserted values started disappearing. That was the ladder working correctly: the page H1 for those records IS the phone number / the industry, because PageHeaderRenderer resolves it through the very same derivation. Each fixture now DECLARES a name field and leaves it UNSET on every record, so the ladder resolves to name, finds no value and hides nothing - the dedupe-neutrality both docstrings always asked for, now stated as a field rather than as an absence. All placeholder counts, section verdicts and assertions are otherwise byte-identical. Flagged because it is the visible edge of a real blast radius: this change also affects objects with no declared nameField, wherever the type-aware derivation answers something the literal walk did not."
      ]
    }

    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

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions