Skip to content

Commit 2c7b4e5

Browse files
baozhoutaoclaude
andcommitted
fix(fields): lookup multi-value hydration batches via $in and shows loading instead of the empty placeholder (#3108)
A multi-value lookup hydrated its committed ids with a SERIAL findOne per id — 41 selected users meant 41 sequential round-trips, and for those seconds the trigger rendered the bare "Select…" placeholder, indistinguishable from having no value at all (downstream users read a populated field as empty and could clear it by hand). - Several unresolved ids now hydrate with one `$in` query per LOOKUP_PAGE_SIZE chunk (chunks in parallel); the single-id case keeps the pre-existing cheap paths (primary-id findOne GET / equality filter for id_field, #3508). - While any committed bare id is still unresolved, the trigger (and the readonly renderer) shows a spinner + "N selected" instead of the empty placeholder. A settled-set keeps the spinner from sticking forever on ids that will never resolve (deleted records, fetch failures) — those fall back to the pre-existing drop behaviour. The committed value itself is never rewritten during hydration (handleSelect/handleRemove operate on the raw value), so submitting mid-hydration was already lossless; the loading state removes the visual lie that invited manual clearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 912496d commit 2c7b4e5

2 files changed

Lines changed: 241 additions & 12 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Regression (#3108): a multi-value lookup hydrated its committed ids with a
5+
* SERIAL `findOne` per id — 41 selected users meant 41 sequential round-trips,
6+
* during which the trigger rendered the bare "Select…" placeholder,
7+
* indistinguishable from having no value at all.
8+
*
9+
* Fixed behaviour under test:
10+
* 1. unresolved ids are fetched in ONE batched `$in` query (chunked), not
11+
* one findOne per id;
12+
* 2. while hydration is in flight the trigger shows a loading indicator and
13+
* the committed-value count instead of the empty placeholder;
14+
* 3. the loading state settles (falls back to placeholder) when hydration
15+
* fails, instead of spinning forever.
16+
*/
17+
18+
import * as React from 'react';
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
21+
import { LookupField } from './LookupField';
22+
23+
afterEach(cleanup);
24+
25+
const USERS = [
26+
{ id: 'u1', name: 'Ada Lovelace' },
27+
{ id: 'u2', name: 'Grace Hopper' },
28+
{ id: 'u3', name: 'Margaret Hamilton' },
29+
];
30+
31+
describe('LookupField — multi-value hydration batches and shows loading (#3108)', () => {
32+
it('hydrates several ids with one $in query instead of a findOne per id', async () => {
33+
const find = vi.fn(async (_obj: string, params: any) => {
34+
const wanted: any[] = params?.$filter?.id?.$in ?? [];
35+
return { data: USERS.filter((u) => wanted.includes(u.id)) };
36+
});
37+
const findOne = vi.fn(async () => null);
38+
render(
39+
<LookupField
40+
value={['u1', 'u2', 'u3']}
41+
onChange={() => {}}
42+
dataSource={{ find, findOne } as never}
43+
field={{ reference_to: 'sys_user', multiple: true } as never}
44+
/>,
45+
);
46+
47+
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
48+
expect(screen.getByText('Grace Hopper')).toBeTruthy();
49+
expect(screen.getByText('Margaret Hamilton')).toBeTruthy();
50+
51+
// One batched round-trip, zero per-id GETs.
52+
expect(findOne).not.toHaveBeenCalled();
53+
const hydrationCalls = find.mock.calls.filter((c) => c[1]?.$filter?.id?.$in);
54+
expect(hydrationCalls).toHaveLength(1);
55+
expect(hydrationCalls[0][1].$filter.id.$in).toEqual(['u1', 'u2', 'u3']);
56+
});
57+
58+
it('shows the committed count + spinner while hydration is in flight, never the empty placeholder', async () => {
59+
let resolveFind!: (v: any) => void;
60+
const find = vi.fn(
61+
() => new Promise((resolve) => { resolveFind = resolve; }),
62+
);
63+
render(
64+
<LookupField
65+
value={['u1', 'u2', 'u3']}
66+
onChange={() => {}}
67+
dataSource={{ find } as never}
68+
field={{ reference_to: 'sys_user', multiple: true } as never}
69+
/>,
70+
);
71+
72+
// In-flight: loading indicator + raw committed count, no "Select…".
73+
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
74+
expect(screen.queryByText('Select...')).toBeNull();
75+
expect(screen.getByText(/3/)).toBeTruthy();
76+
77+
resolveFind({ data: USERS });
78+
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
79+
expect(screen.queryByTestId('lookup-hydrating')).toBeNull();
80+
});
81+
82+
it('settles (no infinite spinner) when the hydration fetch fails', async () => {
83+
const find = vi.fn(async (_obj: string, params: any) => {
84+
if (params?.$filter?.id?.$in) throw new Error('boom');
85+
return { data: [] };
86+
});
87+
render(
88+
<LookupField
89+
value={['u1', 'u2']}
90+
onChange={() => {}}
91+
dataSource={{ find } as never}
92+
field={{ reference_to: 'sys_user', multiple: true } as never}
93+
/>,
94+
);
95+
96+
await waitFor(() => expect(find).toHaveBeenCalled());
97+
// The failed attempt is marked settled — the spinner clears instead of
98+
// spinning forever (pre-existing fallback: unresolvable ids drop).
99+
await waitFor(() => expect(screen.queryByTestId('lookup-hydrating')).toBeNull());
100+
});
101+
102+
it('readonly mode shows the loading count instead of an empty value while hydrating', async () => {
103+
let resolveFind!: (v: any) => void;
104+
const find = vi.fn(
105+
() => new Promise((resolve) => { resolveFind = resolve; }),
106+
);
107+
render(
108+
<LookupField
109+
value={['u1', 'u2']}
110+
onChange={() => {}}
111+
readonly
112+
dataSource={{ find } as never}
113+
field={{ reference_to: 'sys_user', multiple: true } as never}
114+
/>,
115+
);
116+
117+
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
118+
119+
resolveFind({ data: USERS.slice(0, 2) });
120+
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
121+
});
122+
123+
it('keeps the cheap findOne GET for a single unresolved primary id', async () => {
124+
const find = vi.fn(async () => ({ data: [] }));
125+
const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
126+
render(
127+
<LookupField
128+
value={['u1']}
129+
onChange={() => {}}
130+
dataSource={{ find, findOne } as never}
131+
field={{ reference_to: 'sys_user', multiple: true } as never}
132+
/>,
133+
);
134+
await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
135+
});
136+
});

packages/fields/src/widgets/LookupField.tsx

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
205205
// even when the record wasn't part of the Level 1 popover fetch.
206206
const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
207207

208+
// Ids whose hydration attempt has FINISHED — found or not — keyed by
209+
// String(id). Distinguishes "still loading" from "unresolvable" (deleted
210+
// record, fetch failure) so the trigger's hydrating state can't stick
211+
// forever on an id that will never resolve (#3108).
212+
const [hydrationSettled, setHydrationSettled] = useState<Set<string>>(() => new Set());
213+
208214
// Arrow-key active index (-1 = none)
209215
const [activeIndex, setActiveIndex] = useState(-1);
210216
const listRef = useRef<HTMLDivElement>(null);
@@ -476,11 +482,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
476482
(async () => {
477483
try {
478484
const fetched: LookupOption[] = [];
479-
for (const id of unresolved) {
480-
// `findOne` resolves by PRIMARY id only. When the field commits a
481-
// different column (`id_field: 'name'` — e.g. position machine
482-
// names, objectstack #3508), hydrate by filtering on that column or
483-
// the stored value would never resolve to a label.
485+
// Single id: the pre-existing cheap paths — a primary-id `findOne`
486+
// GET, or an equality filter when the field commits a different
487+
// column (`id_field: 'name'` — e.g. position machine names,
488+
// objectstack #3508).
489+
if (unresolved.length === 1) {
490+
const id = unresolved[0];
484491
if (typeof (dataSource as any).findOne === 'function' && idField === 'id') {
485492
const rec = await (dataSource as any).findOne(referenceTo, id);
486493
if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
@@ -489,9 +496,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
489496
$filter: { [idField]: id },
490497
$top: 1,
491498
} as QueryParams);
492-
const rows = res?.data ?? res ?? [];
499+
const rows = (res as any)?.data ?? res ?? [];
493500
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
494501
}
502+
} else {
503+
// SEVERAL unresolved ids: one `$in` query per chunk. A multi-value
504+
// field can hold dozens of ids, and the old serial findOne-per-id
505+
// took seconds while the trigger sat on the empty "Select…"
506+
// placeholder (#3108).
507+
// Chunked to LOOKUP_PAGE_SIZE so no request exceeds the page size
508+
// a server may cap `$top` at; chunks run in parallel.
509+
const chunks: any[][] = [];
510+
for (let i = 0; i < unresolved.length; i += LOOKUP_PAGE_SIZE) {
511+
chunks.push(unresolved.slice(i, i + LOOKUP_PAGE_SIZE));
512+
}
513+
const results = await Promise.all(
514+
chunks.map((chunk) =>
515+
dataSource.find(referenceTo, {
516+
$filter: { [idField]: { $in: chunk } },
517+
$top: chunk.length,
518+
} as QueryParams),
519+
),
520+
);
521+
for (const res of results) {
522+
const rows = (res as any)?.data ?? res ?? [];
523+
if (!Array.isArray(rows)) continue;
524+
for (const row of rows) {
525+
fetched.push(recordToOption(row, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
526+
}
527+
}
495528
}
496529
if (!cancelled && fetched.length) {
497530
setPickerResolvedRecords((prev) => {
@@ -502,6 +535,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
502535
}
503536
} catch {
504537
// Ignore — chip will fall back to showing the raw id.
538+
} finally {
539+
// Mark the attempt settled — found or not — so `hydrating` below
540+
// clears even for ids that no longer resolve (deleted records,
541+
// fetch failures) instead of spinning forever.
542+
if (!cancelled) {
543+
setHydrationSettled((prev) => {
544+
const next = new Set(prev);
545+
for (const id of unresolved) next.add(String(id));
546+
return next;
547+
});
548+
}
505549
}
506550
})();
507551
return () => {
@@ -580,6 +624,31 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
580624
[findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
581625
);
582626

627+
// A value can hold bare ids that no option list resolves YET — the batch
628+
// hydration above is still in flight. While any such id is pending, the
629+
// trigger must not present the field as empty: a 41-value lookup rendered
630+
// the bare "Select…" placeholder for seconds, indistinguishable from
631+
// having no value at all (#3108).
632+
const hydrating = useMemo(() => {
633+
if (!hasDataSource) return false;
634+
const raw: any[] = multiple
635+
? Array.isArray(value) ? value : []
636+
: value != null && value !== '' ? [value] : [];
637+
return raw.some(
638+
(v) =>
639+
v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v) &&
640+
!findOption(v) && !findOptionLoose(v) &&
641+
!hydrationSettled.has(String(v)),
642+
);
643+
}, [hasDataSource, multiple, value, findOption, findOptionLoose, hydrationSettled]);
644+
645+
// Committed-value count. During hydration the resolved-option count
646+
// undercounts (unresolved ids are filtered out below), so the trigger
647+
// shows this raw count instead.
648+
const rawSelectedCount = multiple
649+
? (Array.isArray(value) ? value : []).filter((v) => v != null && v !== '').length
650+
: value != null && value !== '' ? 1 : 0;
651+
583652
const selectedOptions = multiple
584653
? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
585654
: value ? [resolveSelectedOption(value)].filter(Boolean) : [];
@@ -801,6 +870,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
801870

802871
if (readonly) {
803872
if (!selectedOptions.length) {
873+
if (hydrating) {
874+
return (
875+
<span
876+
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
877+
data-testid="lookup-hydrating"
878+
>
879+
<Loader2 className="size-3.5 animate-spin" />
880+
{multiple ? t('table.selected', { count: rawSelectedCount }) : t('lookup.loading')}
881+
</span>
882+
);
883+
}
804884
return <EmptyValue />;
805885
}
806886

@@ -846,15 +926,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
846926
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
847927
: undefined}
848928
>
849-
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
929+
{hydrating ? (
930+
<Loader2
931+
className={cn('size-4 shrink-0 animate-spin text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')}
932+
data-testid="lookup-hydrating"
933+
/>
934+
) : (
935+
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
936+
)}
850937
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
851938
{dependenciesMissing
852939
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
853-
: compact && !multiple && selectedOptions.length > 0
854-
? singleSelectedLabel
855-
: selectedOptions.length === 0
856-
? lookupField?.placeholder || t('common.select')
857-
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
940+
: hydrating
941+
// The value EXISTS but its labels are still loading — say so
942+
// instead of the empty placeholder (#3108).
943+
? multiple
944+
? t('table.selected', { count: rawSelectedCount })
945+
: t('lookup.loading')
946+
: compact && !multiple && selectedOptions.length > 0
947+
? singleSelectedLabel
948+
: selectedOptions.length === 0
949+
? lookupField?.placeholder || t('common.select')
950+
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
858951
</span>
859952
</Button>
860953
);

0 commit comments

Comments
 (0)