Skip to content

fix(fields): render inline lookup dropdown columns through the cell renderer - #5598

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5492-lookup-dropdown-cellrenderer
Aug 21, 2026
Merged

fix(fields): render inline lookup dropdown columns through the cell renderer#5598
os-sales merged 1 commit into
mainfrom
claude/issue-5492-lookup-dropdown-cellrenderer

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5492

The disagreement

A lookup field offers two ways to pick a related record, and both read one
lookup_columns declaration:

surfacewhere it liveshow it rendered a column
inline dropdownthe quick-select popover under the form fieldraw stored value
browse-all pickerRecordPickerDialog's table behind "show all results"the type-aware cell renderer

Both code paths, re-derived on origin/main at cad512fe1 and named as the
card asks:

  • the concatenation site (dropdown, defective) — two of them, in
    packages/fields/src/widgets/LookupField.tsx:
    • the option subtitle came from recordToOption, which set
      description = record[descriptionField] and printed it verbatim;
    • the option tooltip came from previewOf, which built
      `${lbl}: ${text}` per column, text being the raw value, and dropped
      the result into the row's title attribute.
  • the cell-renderer path (picker, correct)
    RecordPickerDialog.renderCellContent, which looked each column up in
    columnFieldDescriptors (enriched from the referenced object's schema) and
    rendered it through the resolver LookupField hands it as
    cellRenderer={getCellRendererResolver()}.

Measured on the branch, driving the same declaration through both surfaces
(this is the ablation output, quoted verbatim below):

column inline dropdown (before) browse-all picker
work_step Zz9QwErTyUiOpAsD (bare FK id) —
planned_on 2026-08-20T00:00:00.000Z (ISO) Aug 20
status pending (enum code) Pending approval

The fix

A new shared module, packages/fields/src/widgets/lookupColumnDisplay.tsx,
becomes the one place that answers "what does column C of this candidate
record display?". It owns column normalisation, the field-descriptor
enrichment from the referenced object's schema, and the render itself.

  • RecordPickerDialog now builds columnFieldDescriptors with
    buildLookupColumnDescriptors and implements renderCellContent as a call
    to renderLookupColumnValue. Its behaviour is unchanged — the logic moved,
    it was not rewritten.
  • LookupField builds the same descriptors from the same
    refObjectSchema.fields and renders its option preview through the same
    renderLookupColumnValue. The subtitle and the old tooltip merge into one
    rendered preview line in the option row; the row's title now carries the
    full option label, which is what a truncated label actually needs, instead of
    a raw-value dump.

There is one renderer left, so the two surfaces cannot drift apart again.

The cost this accepts, stated plainly

Resolving a foreign key to a name is a client-side fetch, so a dropdown page
that previously rendered ids now mounts one lookup cell renderer per lookup
column per option, up to the popover's page size of 50. Those resolutions are
deduplicated by the module-level name cache the picker already fills, and only
lookup-typed columns fetch at all — a date or a select column is pure
formatting. It is the same work the picker has always done over its (smaller,
10-row) page, and it is the work the reporter is asking for: the alternative to
the fetch is the bare id. Flagging it because it is a real change in request
volume on popover open, not because it is believed to be a problem.

What is deliberately NOT here

No query changed and no contract widened. lookupColumns entries stay bare
field names — no dot-path, no populate/expand semantics — and this fix does not
need them: neither surface's request carries populate. Re-derived on the
branch, LookupField and RecordPickerDialog both call useRecordQuery
without an expand argument. The picker resolved foreign-key ids to names
purely client-side all along, in LookupCellRenderer via its fetch-on-demand
useLookupName plus a module-level name cache. The dropdown now inherits
exactly that resolution. So the spec-widening ask on the card is not a
prerequisite for the primary defect, and is not attempted here.

The unresolved-id decision

Because no populate is involved, a lookup column's value can legitimately be an
id that resolves to nothing. The decision: the dropdown adopts whatever the
picker shows, and the column is never dropped.

  • A column is previewed when the record holds a value for it. That is
    decided on the raw value, never on what the renderer makes of it — so an
    unresolved id keeps its slot.
  • What renders in that slot is the lookup cell renderer's own placeholder for a
    present-but-unnamed reference (a muted en dash, still wrapped in the
    referenced-record link so the reference stays openable), not the shared
    empty-value slot that means "this record has nothing here". The test asserts
    that distinction structurally, not by glyph.
  • This is strictly above the floor the card sets. The silent-empty outcome it
    reports for a dot-path attempt is the case where the column disappears; that
    is what the raw-value presence rule rules out.

Tests

packages/fields/src/widgets/LookupField.pickerAgreement.test.tsx pins the
disagreement, not either surface alone: it drives one lookup_columns
declaration through the inline dropdown and through the browse-all picker and
compares them column for column, for a lookup, a date and a select field, in
two states — a reference that resolves and one that does not.

Reverse-verification, both mutations proved on disk with anchored grep -c in
both directions plus git diff --stat, restored by a trap ... EXIT INT TERM,
tree confirmed byte-identical afterwards (git status --porcelain empty, marker
sweep 0/0). No rebuild leg is needed and that is a property of the resolution
path, not an assumption: the test reaches every mutated file through a
relative specifier inside packages/fields/src, so nothing resolves through
a sibling package's exports to a dist tree.

  • Ablation A — the dropdown stops calling the shared renderer and prints the
    raw value again (the pre-fix behaviour). Anchor count 1 to 0, marker 0 to 1,
    1 insertion(+), 5 deletions(-). Predicted red, observed red: 2 of 2 tests
    failed
    , and the failure diff reproduces the card's table exactly (the block
    quoted above is that diff). The fallback assertions behaved as predicted —
    present, emptySlot === false and non-empty still passed under the
    ablation, because the bare id is also non-empty. They are a floor the fix must
    not fall below, not a detector for this mutation; the agreement assertion is
    what turns red.
  • Ablation B — the shared module stops using the cell renderer at all.
    Anchor count 1 to 0, marker 0 to 1, 1 insertion(+), 1 deletion(-). Predicted
    and observed: both surfaces degrade together, so the agreement assertion
    keeps passing while the concrete renderings go red
    (expected 'T5MsMCuwP4t_yUHq' to be 'Fitting, Line A',
    expected 'pending' to be 'Pending approval'). That is the proof that the
    shared module is the live renderer for both surfaces and not just for one.

Gates

Exit codes captured before any pipe; each verdict is the gate's own line.

gateverdict
pnpm --filter @object-ui/fields type-checkos-verify-lock: VERDICT command-exit 0 (tsc --noEmit && tsc -p tsconfig.test.json, no diagnostics)
pnpm --filter @object-ui/fields lintos-verify-lock: VERDICT command-exit 0✖ 869 problems (0 errors, 869 warnings) over 190 files
pnpm exec vitest run packages/fields/ (repo root)Test Files 111 passed (111) / Tests 1839 passed (1839)
node scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 4645 tracked text file(s))
node scripts/check-changeset-presence.mjs✅ 4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
node scripts/check-changeset-fixed.mjs✅ All workspace packages are in the changeset fixed group.
pnpm check:i18n-keys✅ every in-scope call-site key resolves against the en pack (no t() call site changed)
pnpm check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
pnpm check:self-import✅ No package names itself inside its own src/.
pnpm check:esm-specifiersSpecifier leg: no un-ledgered package emits an extensionless relative specifier.
pnpm check:spec-symbols✅ spec symbol derivation: 1289 files scanned
pnpm check:action-forward-parityexit 0
pnpm check:i18n-driftNo en value changed in this range.
node scripts/check-type-check-coverage.mjs✅ type-check coverage: 45/46 ... ✅ test type-check coverage: 41/41
node scripts/check-lint-coverage.mjs✅ lint coverage: 46/46 packages linted, 0 with outstanding errors

All of the above ran on the final commit's tree, 6582f0d2b, with
git status --porcelain empty. type-check and lint were re-run on that
exact tree after the last edit, so no verdict here reports a tree that is no
longer HEAD.

Declared narrowing. Lint ran over packages/fields (190 files, from
eslint's --format json output) rather than the repo-wide eslint .. It cannot
hide a failure elsewhere: eslint.config.js declares no project /
projectService, so type-aware linting is off and every file's verdict is a
function of its own text — this diff touches five files, four of them under
packages/fields/src and one changeset, so no untouched file's verdict can
move. The population is read from eslint's own configuration by
check-lint-coverage.mjs, which reports 46/46 packages in scope at 0 outstanding
errors. Type-check was likewise narrowed to @object-ui/fields: the only
publicly visible type change is CellRendererResolver, which becomes an alias
of a structurally identical type, and the new module is not re-exported from the
package barrel. pnpm check:eager-closure was not run — it reads
apps/console/dist/eager-closure.json and reports itself a broken gauge without
a console build; that build is CI's, and this diff does not touch the console.


Generated by Claude Code

…enderer (#5492)
A lookup's inline dropdown and its "browse all records" picker read one
`lookup_columns` declaration and answered differently: the picker resolved
each cell through the type-aware cell renderer, while the dropdown printed
the raw stored value into the option subtitle and concatenated
`label: String(rawValue)` into the row's `title`. So one declaration
produced a bare foreign-key id beside a resolved name, a raw ISO timestamp
beside a formatted date, and an enum code beside an option label.
Both surfaces now call one shared module, `widgets/lookupColumnDisplay.tsx`,
which owns column normalisation, field-descriptor enrichment from the
referenced object's schema, and the render itself. No query changed and no
contract widened: `lookupColumns` entries stay bare field names, and an
unresolved foreign key is resolved client-side by the lookup cell renderer
exactly as the picker has always done.
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.3 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-Dp62ATHy.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.53KB32.97KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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.08KB1.53KB
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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (card #5492)

Gates. 22 named check runs read individually for completed + success: 19 success, 3 skippedTest (coverage), the unexpanded Test (coverage shard …/4) matrix placeholder, and dependabot, all three always-skipped no-ops in this repo's set. Type Check, Lint, Doc Snippet Type Check, all four real shards, Build & E2E, Build Docs, Bundle Analysis, Live E2E (informational) and the changeset checks are green on the head commit.

Merits. The load-bearing check for this card was whether the popover path already depends on relation expansion, because if it did, the spec-widening ask in the card body would be a prerequisite and this change could not land alone. It does not: the popover query at packages/fields/src/widgets/LookupField.tsx:437 is built from dataSource, objectName: referenceTo, enabled, pageSize: LOOKUP_PAGE_SIZE and filter: popoverFilter — no expand argument anywhere in the call. Verified on disk, not inferred from the card. The spec-widening ask is therefore separable and stays out of this PR.

Diff stays inside the fenced surface; no public surface widened, so clause ② is not engaged.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 16:47
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 7a28e1eAug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5492-lookup-dropdown-cellrenderer branch August 21, 2026 16:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sales@claude