Skip to content

fix(detail): lookup field values link to the referenced record (#4336) - #4489

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4336-detail-lookup-links
Aug 12, 2026
Merged

fix(detail): lookup field values link to the referenced record (#4336)#4489
yinlianghui merged 1 commit into
mainfrom
claude/issue-4336-detail-lookup-links

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#4336

The defect

A valued lookup on a record detail page rendered as plain text plus a copy button: the referenced document's display name was right there, and there was no way to reach it. The reporter's own check — no a element anywhere over the field's value region, every href null — held for readonly and non-readonly lookups alike, across several objects, which is what makes this display-mode rendering rather than an editability gate.

The follow-up comment widened it: on a related list, the ROW navigates to its own record, but lookup cells pointing at a THIRD object were dead in the same way (执行责任人, 管理责任人, 来源维保派工单 — "逐格取 href 全部为 null").

Measurement: one path, so both halves land here

DetailSection's read branch and RelatedList.makeCell each resolve the cell through getCellRenderer('lookup')LookupCellRenderer (packages/fields/src/index.tsx). Same renderer, same path — so the related-list half is covered here, not split into a second card. Pinned separately all the same: the detail body being green is not evidence for the related-list cell.

The URL resolver — measured, and what I found

The ruling asked me to reuse the shared mechanism the list page's link: true columns and related-row navigation build record URLs with. Measured, that mechanism is not a URL helper — the repo has none reachable from a low-level renderer:

  • ObjectGrid's link: true / primary column renders a span role="link" and calls navigation.handleClick(row); useNavigationOverlay delegates to the host's onNavigate.
  • A related list's row navigation calls useRelatedRecordActions().resolve({objectName}).onView(id).
  • In both, the host (app-shell) owns URL assembly — /apps/:app/:object/record/:id is written out in ~15 host-side places, and the one exported helper (deriveRecordPageHref) derives its prefix by finding /{objectName} in the current path, so it cannot address a different object's record.

So the shared mechanism is "host-provided callbacks through React context, host owns the route shape". I reused exactly that, and added no new assembly: RelatedRecordActionsContext — already mounted around the whole detail page body, already the channel the related list navigates through — gains an optional recordHref / openRecord pair. RelatedRecordActionsBridge publishes them from the same builder onView already navigated with: its inner detailUrl is hoisted to recordHref(objectName, id), ?from= breadcrumb trail included, and onView now calls it. One route shape, one place; nothing re-derived renderer-side.

A real anchor with an href rather than a click handler, so middle-click / Cmd-click / "copy link address" behave the way a link should; a plain left click is handed to openRecord so navigation stays in-app, and the click is stopped from reaching the row underneath (which would otherwise ALSO fire the detail row's copy-to-clipboard or the related row's own open).

No host, or a host that cannot route to the target object → no link, and the value renders exactly as before (Studio designer, embedded renderers, standalone grids).

Red-first

Pre-fix, over the three surfaces, verbatim:

FAIL |dom| packages/fields/.../lookupCellRecordLink.test.tsx > expanded value: renders an anchor at the host-built record href
AssertionError: expected null not to be null
FAIL |dom| packages/plugin-detail/.../DetailSection.lookupLink.test.tsx > readonly lookup: the value region carries an anchor at the record href
AssertionError: expected null not to be null
FAIL |dom| packages/plugin-detail/.../DetailSection.lookupLink.test.tsx > non-readonly lookup: same treatment — readonly is irrelevant here
AssertionError: expected null not to be null
FAIL |dom| packages/plugin-detail/.../RelatedList.lookupCellLink.test.tsx > a lookup column pointing at a third object renders an anchor
AssertionError: expected null not to be null
Test Files 3 failed (3)
Tests 10 failed | 8 passed (18)

Post-fix: Test Files 3 passed (3) · Tests 18 passed (18) — the anchor's href is the host-built record URL (/apps/demo/mtc_work_order/record/wo-1).

Must-not-change (green on both sides): display-name resolution unchanged (the anchor carries the same text); the copy button still sits beside the linked value; non-lookup fields untouched; a lookup with no value keeps its placeholder and grows no empty link; no host → plain text.

Edge cases

caseanswer
no valueplaceholder, no anchor (unchanged)
target object not routable by the hostrecordHref returns null → plain value, exactly as before
multi-valueeach chip is one referenced record, so each links on its own — there is no single destination a multi-value cell could point at
value present, display name unresolvable (opaque id → muted placeholder)linked; the value IS the foreign key, so the record is addressable even unnamed
unresolved external-id reference ({"externalId":…})no record id → unlinked

Verification

  • pnpm exec vitest run packages/fields/ packages/plugin-detail/ packages/react/ packages/plugin-grid/ packages/plugin-dashboard/ packages/plugin-kanban/ packages/plugin-list/350 files / 4166 tests passed
  • pnpm exec vitest run packages/app-shell/ apps/console/404 files / 3945 passed, 1 skipped
  • type-check (both tsc commands where the package has them) for @object-ui/fields, @object-ui/react, @object-ui/app-shell → all exit 0
  • check:control-bytes, check:changeset-presence, check:changeset-no-major, check:spec-symbols, check:phantom-deps, check:i18n-keys, eslint on touched files (0 errors) → green

Grading

.d.ts measured after a dependency-closure build: @object-ui/react's emitted declaration gains the two optional members → minor. @object-ui/fields' declaration is unchanged (LookupCellRenderer's signature identical; the link component and id helper are module-private) and @object-ui/app-shell's bridge props are unchanged → patch for both.

Not in scope

The ${param._rowRecord.«lookupField»} shape inconsistency from the follow-up's item 4 (expand-object in header actions vs bare id in row actions) is untouched, per the ruling.


Generated by Claude Code

A valued lookup on a record detail page rendered as plain text plus a copy
button — the referenced document's name was visible and unreachable, so users
copied the number and searched for it from the list page instead. Lookup cells
in a related list that pointed at a third object were dead the same way.
Measurement put both halves on ONE path: `DetailSection`'s read branch and
`RelatedList.makeCell` each resolve the cell through `getCellRenderer('lookup')`
→ `LookupCellRenderer`, so the affordance goes there, once.
The URL is not assembled in the renderer, which has no router and no business
knowing the console's route shape. `RelatedRecordActionsContext` — already the
channel the related list's row navigation uses — gains an optional
`recordHref` / `openRecord` pair, published by `RelatedRecordActionsBridge`
from the SAME builder `onView` navigated with (its inner `detailUrl` hoisted,
`?from=` trail included). One record-route shape, not a second one. A host
that provides neither (Studio designer, embedded renderers, standalone grids)
renders exactly what it rendered before.
A real `<a href>` rather than a click handler, so middle-click / ⌘-click /
"copy link address" work; a plain left click is handed to the host so
navigation stays in-app, and the click never reaches the row underneath (which
would otherwise also fire the detail row's copy or the related row's own open).
Red-first: pre-fix the value region contains no `<a>` (the reporter's own DOM
check) on the detail body, the related-list cell and the renderer itself;
green both sides for display-name resolution, the copy button, non-lookup
fields, and a lookup with no value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 12, 2026 8:28pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-C5MzUwCM.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.32KB108.45KB
core (index.js)3.37KB1.34KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.42KB41.19KB
fields (index.js)230.07KB57.07KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)45.46KB12.52KB
plugin-charts (index.js)62.01KB17.63KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)120.75KB31.38KB
plugin-designer (index.js)212.58KB42.83KB
plugin-detail (index.js)239.03KB59.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)188.13KB50.00KB
plugin-kanban (index.js)48.62KB13.42KB
plugin-list (index.js)110.47KB26.90KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.16KB10.96KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.08KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.73KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review).

  • The url_resolver measurement refutes my ruling's premise and the landing is the correct consequence: there is no shared URL helper — hosts own route assembly across ~15 hand-written sites, and the one exported helper structurally cannot address a different object. Extending the host contract (RelatedRecordActionsContext.recordHref/openRecord, published by the bridge hoisting its own existing builder — one builder, not two) is the only landing that satisfies both "no hand-built URL in the renderer" and "the value is a real anchor". The out-of-enumeration files (react context + app-shell bridge) are declared, collision-free, and ACCEPTED — the alternative was a 16th assembly site, which console: copied invitation link is unusable — buildAcceptUrl rebuilds the trailing-dot host that resolveHomeUrl already retired #4472's lesson forbids.
  • Same-path verdict for related-list cells measured (both halves through LookupCellRenderer), pinned separately so detail-green is not mistaken for cell-evidence. Escalation check ran and found no deliberate-design ruling.
  • Red-first honest including the fixture harness correction (auto-hide-empty heuristic) fixed BEFORE implementation with red re-captured. Edge cases answered faithfully — per-chip multi-value as the only coherent generalization, unnamed-but-addressable linked, external-id unlinked, no-host byte-identical.
  • The anchor-vs-LinkCell contradiction is resolved in the right direction: a real <a href> (modifier clicks to the browser, left click to the host) is strictly better than the list column's span role="link" with no href. The list column is now the recorded inconsistency — the PM is filing it as a finding.
  • .d.ts measured: react minor (two additive optional members), fields/app-shell patch. Blast radius bounded by where the provider mounts (detail page body only) and confirmed by 8000+ green tests across every getCellRenderer consumer. CI 18/18 green. The pre-existing a11y nesting note (interactive children inside a role="button" row) stays recorded in the PR body.

Flipping ready + arming auto-merge.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 20:42
@yinlianghui
yinlianghui added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit b953a97Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4336-detail-lookup-links branch August 12, 2026 20:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

记录详情页的 lookup 字段渲染成纯文本+复制按钮,不可点击跳转到被引用记录(列表页的链接列正常)

2 participants

@yinlianghui@claude