From eb5ddeb26cd71e5bceca42096551d93f2d0b803d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:14:06 +0000 Subject: [PATCH] fix(app-shell,react): converge the five three-rung `titleField` seams on the two-rung shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectView.tsx` resolves a title field for seven view kinds and answered the same question two ways. Calendar (`viewDef.calendar?.titleField || 'name'`) and gantt already used two rungs; timeline, kanban, map, gallery and tree carried a three-rung chain with the object-level `objectDef.titleField` in the middle. This drops that middle rung, so all seven now read `viewDef.?.titleField || 'name'` — a convergence on an existing shape, not a removal. No legal metadata could reach the rung: `@objectstack/spec`'s object schema is a `strictObject`, so `ObjectSchema.safeParse({ …, titleField: 'x' })` is rejected with `unrecognized_keys` — the same code a nonsense key gets — while `nameField`, `displayNameField` and `titleFormat` all parse (re-measured here against `@objectstack/spec@17.2.0`). objectui#6531 established that and dropped the twin read inside `getRecordDisplayName`. `useRecordSearch`'s candidate signature appended `o?.titleField ?? ''` to every entry, a half that could never vary. The signature is now the object name alone — the only field of an object definition the fanout effect consumes. `timelineViewOptions` loses its now-dead `objectDef` parameter (root tsconfig sets `noUnusedParameters`), and three doc comments that credited the object with supplying the title field are corrected. Fixture triage: two phantom fixtures spelled `titleField: 'name'`, which is also the literal floor, so their assertions resolved identically with the rung deleted. Both retired; the removal is pinned with a distinguishable value in the new convergence suite instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .../6557-objectview-titlefield-middle-leg.md | 41 +++ .../views/ObjectView.timelineBinding.test.tsx | 36 +- .../ObjectView.titleFieldConvergence.test.tsx | 310 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 30 +- .../hooks/__tests__/useRecordSearch.test.ts | 83 ++++- packages/react/src/hooks/useRecordSearch.ts | 14 +- 6 files changed, 487 insertions(+), 27 deletions(-) create mode 100644 .changeset/6557-objectview-titlefield-middle-leg.md create mode 100644 packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx diff --git a/.changeset/6557-objectview-titlefield-middle-leg.md b/.changeset/6557-objectview-titlefield-middle-leg.md new file mode 100644 index 0000000000..13424aa2ac --- /dev/null +++ b/.changeset/6557-objectview-titlefield-middle-leg.md @@ -0,0 +1,41 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/react': patch +--- + +Drop the undeclared object-level `titleField` from the object page's five +remaining view-config seams, and from the record-search memo key + +`ObjectView` resolves a title field for seven view kinds. Two of them — +calendar and gantt — already read `viewDef.?.titleField || 'name'`. The +other five (timeline, kanban, map, gallery, tree) carried a three-rung chain +with `objectDef.titleField` in the middle, so one file answered the same +question two different ways. This converges the five on the shape the two +siblings already had; it is a convergence, not a removal. + +The middle rung could never fire for legal metadata. `@objectstack/spec`'s +object schema is a `strictObject`, so +`ObjectSchema.safeParse({ …, titleField: 'x' })` is rejected with +`unrecognized_keys` — the same issue code a nonsense key gets — while +`nameField`, `displayNameField` and `titleFormat` all parse (measured against +`@objectstack/spec@17.2.0`, the dist this repo installs). objectui#6531 +established that measurement and dropped the twin read inside +`getRecordDisplayName`. Reading a key no producer can ship is the +consumer-side alias AGENTS.md Commandment #0.1 bans. + +Behaviour for every legal config is unchanged, and both directions are pinned: +a view that declares its own `titleField` still wins on every kind, a view that +declares none still floors at `'name'`, and an object carrying the +contract-rejected key is now honoured by no kind. Re-pointing the middle rung +at the declared `nameField` was considered and rejected: it would have added a +rung calendar and gantt do not have — increasing the divergence — and, unlike +this change, it would have altered behaviour for legal configs. + +`useRecordSearch`'s candidate signature — the memo key that decides when the +cross-object fanout re-runs — appended `o?.titleField ?? ''` to every entry. +Because no legal object definition can carry the key, that half was permanently +`''`: a constant suffix in a cache signature, and the last thing in the repo +that read as evidence some producer supplies it. The signature is now the object +name alone, which is the only field of an object definition the effect actually +consumes. Change detection is unaffected — a changed candidate name still +re-runs the fanout, and a new array with identical content still does not. diff --git a/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx b/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx index 79e010919b..ef607898a9 100644 --- a/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx +++ b/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx @@ -23,19 +23,28 @@ * (they read `'due_date'`), while every other case in this file and in * `ListView.timeline-binding.test.tsx` stays green — the fabricated value is * only ever observable when the view declared nothing. + * + * FIXTURE TRIAGE (objectui#6557). The shared `objectDef` here used to be + * `{ name: 'crm_campaign', titleField: 'name' }`, and the no-timeline-config + * case below asserted `out.titleField === 'name'` while its comment credited + * the OBJECT with supplying it. That fixture pinned nothing: `'name'` is also + * the literal floor, so the assertion resolved identically with the object leg + * deleted — and the key itself is one `@objectstack/spec`'s `strictObject` + * REJECTS (`unrecognized_keys`), so no legal object could carry it. The leg is + * gone and the function now takes only `viewDef`; what the object-level key + * does NOT do is pinned with a DISTINGUISHABLE value in + * `ObjectView.titleFieldConvergence.test.tsx`, where `'name'`-vs-`'headline'` + * can actually tell the two worlds apart. */ import { describe, it, expect } from 'vitest'; import { timelineViewOptions } from './ObjectView'; -const objectDef = { name: 'crm_campaign', titleField: 'name' }; - describe('timelineViewOptions — the object page forwards, it does not resolve (#3129)', () => { it('forwards a declared spec binding untouched', () => { - const out = timelineViewOptions( - { timeline: { startDateField: 'start_date', endDateField: 'end_date', scale: 'month' } }, - objectDef, - ); + const out = timelineViewOptions({ + timeline: { startDateField: 'start_date', endDateField: 'end_date', scale: 'month' }, + }); expect(out.startDateField).toBe('start_date'); expect(out.endDateField).toBe('end_date'); // Every spec key survives — the whole config is spread, not whitelisted. @@ -43,12 +52,12 @@ describe('timelineViewOptions — the object page forwards, it does not resolve }); it('promotes the legacy `dateField` alias onto the spec key', () => { - expect(timelineViewOptions({ timeline: { dateField: 'start_date' } }, objectDef).startDateField) + expect(timelineViewOptions({ timeline: { dateField: 'start_date' } }).startDateField) .toBe('start_date'); }); it('invents NO date field when the view declares none', () => { - const out = timelineViewOptions({ timeline: { titleField: 'campaign_name' } }, objectDef); + const out = timelineViewOptions({ timeline: { titleField: 'campaign_name' } }); expect(out.startDateField).toBeUndefined(); expect(out.titleField).toBe('campaign_name'); }); @@ -56,14 +65,15 @@ describe('timelineViewOptions — the object page forwards, it does not resolve it('invents NO date field for a view with no timeline config at all', () => { // The calendar-bound view from the report: the axis lives under `calendar`, // and leaving `startDateField` absent here is what lets ListView find it. - const out = timelineViewOptions({ calendar: { startDateField: 'start_date' } }, objectDef); + const out = timelineViewOptions({ calendar: { startDateField: 'start_date' } }); expect(out.startDateField).toBeUndefined(); - // The object's declared title field is the one thing this layer still - // contributes — ListView has no access to objectDef. + // With no timeline config at all there is nothing to forward, so the + // literal floor answers — the same two-rung shape the calendar and gantt + // branches of this page have always used. expect(out.titleField).toBe('name'); }); - it("falls back to 'name' when the object declares no titleField", () => { - expect(timelineViewOptions({}, { name: 'crm_campaign' }).titleField).toBe('name'); + it("falls back to 'name' when the view declares no titleField", () => { + expect(timelineViewOptions({}).titleField).toBe('name'); }); }); diff --git a/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx new file mode 100644 index 0000000000..1249ba4aab --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -0,0 +1,310 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6557 — the object page's seven view-config `titleField` seams answer + * with ONE shape. + * + * Five of them used to carry a three-rung chain with an object-level + * `objectDef.titleField` in the middle (timeline, kanban, map, gallery, tree), + * while calendar and gantt in the same object literal already used two rungs + * (`viewDef.?.titleField || 'name'`). So this is not a removal — it is + * five sites converging on the shape two sibling sites already had. + * + * WHY THE MIDDLE RUNG COULD NEVER FIRE LEGALLY. `@objectstack/spec`'s object + * schema is a `strictObject`: `ObjectSchema.safeParse({…, titleField: 'x' })` + * is REJECTED with `unrecognized_keys` — the same issue code a nonsense key + * gets — while `nameField`, `displayNameField` and `titleFormat` all parse + * (measured against `@objectstack/spec@17.2.0`, the dist this repo installs). + * objectui#6531 established that and dropped the twin read inside + * `getRecordDisplayName`. + * + * BOTH DIRECTIONS ARE PINNED, because proving only the first is + * evidence-identical to having broken the fallback chain outright: + * + * - THE FIX — an object carrying the contract-rejected `titleField` no longer + * has it honoured anywhere. The value used here is `'headline'`, chosen so + * it is DISTINGUISHABLE from the `'name'` floor; a fixture spelling + * `titleField: 'name'` cannot tell the two worlds apart, which is exactly + * how the retired fixtures in `ObjectView.timelineBinding.test.tsx` and + * `useRecordSearch.test.ts` passed while measuring nothing. + * - THE CONTROLS — a LEGAL config resolves identically before and after: a + * view that declares its own `titleField` still wins on every kind, and a + * view that declares none still floors at `'name'`. Both are green in + * either world. + * + * REVERSE VERIFICATION — direction predicted before running, then observed; see + * the PR body. Restore any one of the five middle rungs and the "does not + * honour" case goes RED for that kind alone (it reads `'headline'`), while both + * control cases and the calendar/gantt columns stay green. + * + * The last case is structural rather than behavioural on purpose: the four + * inline seams are closures inside `ObjectViewInner`, and "these five now have + * the same SHAPE as those two" is a statement about the expressions, not about + * one pair of values. Same posture as `ObjectView.viewConfigGate.test.ts`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +vi.mock('@object-ui/permissions', () => ({ + usePermissions: () => ({ + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), +})); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', () => ({ + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +/** The list schema this page hands down — captured, not rendered. */ +let captured: any = null; +vi.mock('@object-ui/plugin-list', () => ({ + ListView: (props: any) => { + captured = props.schema; + return null; + }, +})); + +// The plugin owns the view chrome; this page owns `renderListView`, which is +// what builds the `options` payload under test. The stub drives that render +// prop directly so the assertion is about THIS file's seams. +vi.mock('@object-ui/plugin-view', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectView: (props: any) => + props.renderListView?.({ + schema: props.schema ?? {}, + dataSource: props.dataSource, + onEdit: props.onEdit, + className: '', + refreshKey: 0, + }) ?? null, + ViewTabBar: () => null, + ManageViewsDialog: () => null, +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'showcase_invoice'; + +/** The contract-rejected object-level key, spelled so it cannot be the floor. */ +const REJECTED = 'headline'; + +function objectsWith(objectExtra: Record, view: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Invoice', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + headline: { type: 'text', label: 'Headline' }, + }, + listViews: { + primary: { label: 'All', type: 'grid', columns: ['name'], ...view }, + }, + ...objectExtra, + }, + ]; +} + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** Render the object list and return the seven resolved seams. */ +async function resolveSeams(objects: any[]) { + captured = null; + render( + + + + {}} />} + /> + + + , + ); + await waitFor(() => { + expect(captured?.options).toBeTruthy(); + }); + const o = captured.options; + return { + timeline: o.timeline?.titleField, + kanban: o.kanban?.titleField, + map: o.map?.titleField, + gallery: o.gallery?.titleField, + tree: o.tree?.labelField, + // The two that were already two-rung — the evidence, never edited. + calendar: o.calendar?.titleField, + gantt: o.gantt?.titleField, + }; +} + +beforeEach(() => { + cleanup(); + captured = null; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('ObjectView view-config `titleField` — the middle rung is gone (objectui#6557)', () => { + it('THE FIX: an object-level `titleField` is honoured by NO view kind', async () => { + const seams = await resolveSeams(objectsWith({ titleField: REJECTED }, {})); + + // Every kind floors at 'name'. Before this change the five would each read + // 'headline' while calendar/gantt read 'name' — the divergence itself. + expect(seams).toEqual({ + timeline: 'name', + kanban: 'name', + map: 'name', + gallery: 'name', + tree: 'name', + calendar: 'name', + gantt: 'name', + }); + }); + + it("CONTROL: a legal view that declares nothing still floors at 'name'", async () => { + const seams = await resolveSeams(objectsWith({}, {})); + + expect(seams).toEqual({ + timeline: 'name', + kanban: 'name', + map: 'name', + gallery: 'name', + tree: 'name', + calendar: 'name', + gantt: 'name', + }); + }); + + it("CONTROL: a legal view's OWN declared `titleField` still wins on every kind", async () => { + // Declared per-kind view keys — real, spec-declared, and untouched by this + // change. Each is distinct so a seam reading the wrong kind's config fails + // here instead of passing by coincidence. The object ALSO carries the + // rejected key, so this doubles as proof the removal did not simply + // hard-code the floor. + const seams = await resolveSeams( + objectsWith( + { titleField: REJECTED }, + { + timeline: { titleField: 'v_timeline' }, + kanban: { titleField: 'v_kanban' }, + map: { titleField: 'v_map' }, + gallery: { titleField: 'v_gallery' }, + tree: { labelField: 'v_tree' }, + calendar: { titleField: 'v_calendar' }, + gantt: { titleField: 'v_gantt' }, + }, + ), + ); + + expect(seams).toEqual({ + timeline: 'v_timeline', + kanban: 'v_kanban', + map: 'v_map', + gallery: 'v_gallery', + tree: 'v_tree', + calendar: 'v_calendar', + gantt: 'v_gantt', + }); + }); + + it("CONTROL: the tree's second view-declared rung (`tree.titleField`) still answers", async () => { + // The tree seam is the only one with TWO view-declared rungs; dropping the + // object rung must not have collapsed them into one. + const seams = await resolveSeams( + objectsWith({ titleField: REJECTED }, { tree: { titleField: 'v_tree_title' } }), + ); + expect(seams.tree).toBe('v_tree_title'); + }); +}); + +describe('the seven seams share ONE expression shape (objectui#6557)', () => { + const SOURCE = readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), + 'utf8', + ); + + /** Every `titleField:` / `labelField:` assignment in the file. */ + const seamLines = SOURCE.split('\n').filter((l) => /^\s*(titleField|labelField):/.test(l)); + + it('there are exactly seven of them', () => { + expect(seamLines).toHaveLength(7); + }); + + it('none reads the object definition', () => { + // The invariant this card restores. A new view kind copied from a sibling + // cannot reintroduce the rung without failing here. + for (const line of seamLines) expect(line).not.toMatch(/objectDef/); + }); + + it("each is a chain of view-declared rungs floored at 'name'", () => { + for (const line of seamLines) { + expect(line).toMatch(/viewDef/); + expect(line.trimEnd()).toMatch(/\|\| 'name',$/); + } + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 4a92525d4b..29936e6d52 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -148,12 +148,17 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any { * fallback entirely. The result on a calendar-bound view was a Timeline the * switcher offered and the renderer bucketed wholly into "No date" (objectui#3129). * - * What stays here is the one thing this layer knows and `ListView` does not: the - * object's declared `titleField`. + * What stays here is the view's OWN declared config, floored at `'name'` — the + * same two-rung shape the calendar and gantt branches below already use. An + * object-level `objectDef.titleField` leg used to sit in the middle of that + * chain; it was removed in objectui#6557 because `@objectstack/spec`'s object + * schema is a `strictObject` that REJECTS the key with `unrecognized_keys`, so + * no legal object metadata could ever reach it (objectui#6531 established the + * measurement, and dropped the twin read inside `getRecordDisplayName`). * * Exported for the regression suite. */ -export function timelineViewOptions(viewDef: any, objectDef: any): Record { +export function timelineViewOptions(viewDef: any): Record { const declaredStart = viewDef?.timeline?.startDateField || viewDef?.timeline?.dateField; return { // Spread the full view-defined timeline config first so the spec fields @@ -161,7 +166,7 @@ export function timelineViewOptions(viewDef: any, objectDef: any): Record { }); }); }); + +/** + * objectui#6557 — the candidate signature is the memo key that decides when the + * cross-object fanout re-runs. It used to append `o?.titleField ?? ''` to every + * entry. + * + * BOTH DIRECTIONS are pinned here, because proving only the first is + * evidence-identical to having broken change detection outright: + * + * - THE FIX: a new `objects` array whose only difference is the + * contract-rejected `titleField` no longer re-runs the fanout. Restore the + * old signature line and this case goes RED (the key changes the string, so + * the effect refires). + * - THE CONTROLS: a real change (a candidate NAME) still refires, and a new + * array with identical content still does not. Both are green in either + * world — which is what makes them controls rather than a second copy of the + * first assertion. + */ +describe('candidate signature (objectui#6557)', () => { + const base = [ + { name: 'account', label: 'Account' }, + { name: 'contact', label: 'Contact' }, + ]; + /** Fresh array + fresh element identities, so only CONTENT can be the cause. */ + const clone = (extra: Record = {}) => base.map((o) => ({ ...o, ...extra })); + + async function mounted(ds: any) { + const view = renderHook( + ({ objects }: { objects: any[] }) => + useRecordSearch({ query: 'acme', objects, dataSource: ds, debounceMs: 0 }), + { initialProps: { objects: clone() } }, + ); + await waitFor(() => { + expect(ds.find).toHaveBeenCalledTimes(2); + }); + ds.find.mockClear(); + return view; + } + + it('does NOT re-run when the only difference is the contract-rejected `titleField`', async () => { + const ds = makeDataSource({}); + const { rerender } = await mounted(ds); + + // `@objectstack/spec`'s object schema REJECTS this key with + // `unrecognized_keys`, so this array is not metadata any producer could + // ship — it is the shape the deleted half of the signature was reacting to. + rerender({ objects: clone({ titleField: 'headline' }) }); + await new Promise((r) => setTimeout(r, 30)); + + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('CONTROL: a new array with identical content still does not re-run', async () => { + const ds = makeDataSource({}); + const { rerender } = await mounted(ds); + + rerender({ objects: clone() }); + await new Promise((r) => setTimeout(r, 30)); + + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('CONTROL: a changed candidate NAME still re-runs the fanout', async () => { + const ds = makeDataSource({}); + const { rerender } = await mounted(ds); + + rerender({ objects: [{ name: 'account', label: 'Account' }, { name: 'lead', label: 'Lead' }] }); + + await waitFor(() => { + expect(ds.find).toHaveBeenCalledWith('lead', expect.objectContaining({ $search: 'acme' })); + }); + }); +}); diff --git a/packages/react/src/hooks/useRecordSearch.ts b/packages/react/src/hooks/useRecordSearch.ts index 740785ba3e..b87f1b5934 100644 --- a/packages/react/src/hooks/useRecordSearch.ts +++ b/packages/react/src/hooks/useRecordSearch.ts @@ -187,8 +187,20 @@ export function useRecordSearch(opts: UseRecordSearchOptions): UseRecordSearchRe return pool; }, [objects, objectNames]); + // The object NAME is the whole signature: it is the only field of an object + // definition this effect consumes (`dataSource.find(obj.name, …)` and the + // `objects` whitelist sent to `searchAll`); `label` / `icon` are read per hit + // at render time and never decide whether the fanout re-runs. + // + // An `o?.titleField ?? ''` half used to be appended to each entry. It could + // never vary: `@objectstack/spec`'s object schema is a `strictObject` that + // REJECTS `titleField` with `unrecognized_keys` (objectui#6531), so no legal + // object metadata carries it and the half was permanently `''` — a constant + // suffix in a cache key, which also read as evidence that some producer ships + // the key. Removed in objectui#6557; the display name comes from + // `getRecordDisplayName`, which does not consult it either. const candidateSignature = useMemo(() => { - return candidates.map((o) => `${o?.name}:${o?.titleField ?? ''}`).join('|'); + return candidates.map((o) => String(o?.name)).join('|'); }, [candidates]); // Run ID for racing-request guarding. Stable across renders.