@@ -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