@@ -391,7 +391,8 @@ private FormAIController applyValueOptions(ValueOptions<?> config) {
391391 // forField(HasValue) accepts MultiSelect fields whose static reference
392392 // is upcast. Reject so the typed MultiSelect overload is the only
393393 // entry for multi-select registrations.
394- var isMultiSelect = config .field () instanceof MultiSelect ;
394+ var field = config .field ();
395+ var isMultiSelect = field instanceof MultiSelect ;
395396 if (!config .isMulti () && isMultiSelect ) {
396397 throw new IllegalArgumentException (
397398 "Field implements MultiSelect — declare the reference as "
@@ -400,49 +401,89 @@ private FormAIController applyValueOptions(ValueOptions<?> config) {
400401 }
401402 // Collection-valued fields must implement MultiSelect — otherwise
402403 // there is no defined aggregation for the resolved items.
403- if (!isMultiSelect
404- && config .field ().getEmptyValue () instanceof Collection ) {
404+ if (!isMultiSelect && field .getEmptyValue () instanceof Collection ) {
405405 throw new IllegalArgumentException (
406406 "Field's value type is a Collection but the field does "
407407 + "not implement MultiSelect. Collection-valued "
408408 + "fields must implement MultiSelect to be "
409409 + "registered via fieldValueOptions(...)." );
410410 }
411- var labeler = resolveItemLabeler (config .field (),
412- config .itemLabelGenerator ());
413- var hints = hintsFor (config .field ());
414- hints .itemLabelGenerator = labeler ;
415- // Items the converter resolves chosen labels against. Pre-populated
416- // with the fixed list, or appended on each query-callback invocation.
417- var observedItems = new ArrayList <>();
418- hints .valueOptionsItems = observedItems ;
411+ var hints = hintsFor (field );
412+ var explicit = config .itemLabelGenerator ();
419413 if (fixed != null ) {
420414 @ SuppressWarnings ({ "unchecked" , "rawtypes" })
421415 List <Object > items = (List ) fixed ;
422- observedItems .addAll (items );
423- warnOnDuplicateLabels (items , labeler );
424- hints .valueOptionsQuery = (filter , limit ) -> filterAndLimit (items ,
425- filter , limit , labeler );
426416 hints .fixedOptions = true ;
417+ hints .valueOptionsTurnSetup = () -> rebindFixedOptions (hints , field ,
418+ explicit , items );
419+ hints .valueOptionsTurnSetup .run ();
420+ warnOnDuplicateLabels (items , hints .itemLabelGenerator );
427421 } else {
428422 @ SuppressWarnings ({ "unchecked" , "rawtypes" })
429423 BiFunction <String , Integer , List <Object >> rawQuery = (BiFunction ) query ;
430- hints .valueOptionsQuery = (filter , limit ) -> {
431- var batch = rawQuery .apply (filter , limit );
432- observedItems .addAll (batch );
433- return batch .stream ().map (labeler ).toList ();
434- };
435424 hints .fixedOptions = false ;
425+ hints .valueOptionsTurnSetup = () -> rebindQueryOptions (hints , field ,
426+ explicit , rawQuery );
427+ hints .valueOptionsTurnSetup .run ();
436428 }
437429 return this ;
438430 }
439431
432+ /**
433+ * Rebuilds the fixed-options bindings on {@code hints} from the original
434+ * item list and the current state of {@code field} / {@code explicit}.
435+ * Invoked at registration and again on every {@link #onRequest()} so the
436+ * labeler captured in {@link FormFieldHints#itemLabelGenerator} reflects
437+ * the field's current {@code setItemLabelGenerator(...)}.
438+ */
439+ private static void rebindFixedOptions (FormFieldHints hints ,
440+ HasValue <?, ?> field , ItemLabelGenerator <?> explicit ,
441+ List <Object > items ) {
442+ var labeler = resolveItemLabeler (field , explicit );
443+ hints .itemLabelGenerator = labeler ;
444+ var map = new LinkedHashMap <String , Object >(items .size ());
445+ for (var item : items ) {
446+ map .putIfAbsent (labeler .apply (item ), item );
447+ }
448+ hints .valueOptionsItems = map ;
449+ hints .valueOptionsQuery = (filter , limit ) -> filterLabels (map .keySet (),
450+ filter , limit );
451+ }
452+
453+ /**
454+ * Rebuilds the query-options bindings on {@code hints} and resets the
455+ * observed-items map. The wrapped query callback dedupes by label as
456+ * batches arrive (first item per label wins) so repeated overlapping
457+ * queries within the same turn don't inflate the map.
458+ */
459+ private static void rebindQueryOptions (FormFieldHints hints ,
460+ HasValue <?, ?> field , ItemLabelGenerator <?> explicit ,
461+ BiFunction <String , Integer , List <Object >> rawQuery ) {
462+ var labeler = resolveItemLabeler (field , explicit );
463+ hints .itemLabelGenerator = labeler ;
464+ var map = new LinkedHashMap <String , Object >();
465+ hints .valueOptionsItems = map ;
466+ hints .valueOptionsQuery = (filter , limit ) -> {
467+ var batch = rawQuery .apply (filter , limit );
468+ var labels = new ArrayList <String >(batch .size ());
469+ for (var item : batch ) {
470+ var label = labeler .apply (item );
471+ labels .add (label );
472+ map .putIfAbsent (label , item );
473+ }
474+ return labels ;
475+ };
476+ }
477+
440478 /**
441479 * Logs a warning when two or more items in a fixed-options registration
442- * render to the same label. Resolution falls back to first-in-list
443- * ordering, so duplicates are recoverable but ambiguous — a unique
480+ * render to the same label. Resolution under
481+ * {@link FormFieldHints#valueOptionsItems} keeps only the first-per-label
482+ * (the Map's {@code putIfAbsent} semantic), so duplicates are recoverable
483+ * but ambiguous — a unique
444484 * {@link ValueOptions#itemLabelGenerator(ItemLabelGenerator)} is the
445- * unambiguous fix.
485+ * unambiguous fix. Fires once at registration; a labeler swap between turns
486+ * that introduces new duplicates does not re-warn.
446487 */
447488 private static void warnOnDuplicateLabels (List <Object > items ,
448489 Function <Object , String > labeler ) {
@@ -469,7 +510,8 @@ private static void warnOnDuplicateLabels(List<Object> items,
469510 * Priority: an explicit {@link ItemLabelGenerator} on the registration,
470511 * otherwise the field's own {@code getItemLabelGenerator()} (via
471512 * {@link FormValueConverter#renderItem}, which also covers the
472- * {@link String#valueOf} fallback).
513+ * {@link String#valueOf} fallback). Called once per turn at
514+ * {@link #onRequest()} so a swap on the field between turns is picked up.
473515 */
474516 private static Function <Object , String > resolveItemLabeler (
475517 HasValue <?, ?> field , ItemLabelGenerator <?> explicit ) {
@@ -729,10 +771,23 @@ public void onRequest() {
729771 // are picked up.
730772 attachIds ();
731773 seedDescriptionsFromBinder ();
774+ refreshValueOptionsBindings ();
732775 lockFields ();
733776 snapshotPreTurnValues ();
734777 }
735778
779+ /**
780+ * Re-runs each value-options registration's setup at the start of a turn.
781+ * Captures the current item-label generator from the field so a swap
782+ * between turns is reflected, repopulates the fixed-options map from its
783+ * immutable list, and clears the query-options map so each turn starts with
784+ * a fresh cache.
785+ */
786+ private void refreshValueOptionsBindings () {
787+ hintsById .values ().stream ().map (hints -> hints .valueOptionsTurnSetup )
788+ .filter (Objects ::nonNull ).forEach (Runnable ::run );
789+ }
790+
736791 @ Override
737792 public void onResponse (Throwable error ) {
738793 try {
@@ -978,13 +1033,13 @@ private static String getOrCreateId(HasValue<?, ?> field) {
9781033 return id ;
9791034 }
9801035
981- private static List <String > filterAndLimit ( List < Object > source ,
982- String filter , int limit , Function < Object , String > labeler ) {
983- var stream = source .stream (). map ( labeler );
1036+ private static List <String > filterLabels ( Collection < String > labels ,
1037+ String filter , int limit ) {
1038+ var stream = labels .stream ();
9841039 if (filter != null && !filter .isEmpty ()) {
9851040 var needle = filter .toLowerCase (Locale .ROOT );
986- stream = stream
987- . filter ( o -> o .toLowerCase (Locale .ROOT ).contains (needle ));
1041+ stream = stream . filter (
1042+ label -> label .toLowerCase (Locale .ROOT ).contains (needle ));
9881043 }
9891044 return stream .limit (limit ).toList ();
9901045 }
0 commit comments