diff --git a/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php b/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..6afc71a2eda499cb1a8ff7757f7542703517870d
--- /dev/null
+++ b/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php
@@ -0,0 +1,74 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Core\Service\AutoService;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Preprocess api autocomplete requests
+ * @service
+ * @internal
+ */
+class AutocompleteFieldSubscriber extends AutoService implements EventSubscriberInterface {
+
+  /**
+   * @return array
+   */
+  public static function getSubscribedEvents() {
+    return [
+      'civi.api.prepare' => ['onApiPrepare', -50],
+    ];
+  }
+
+  /**
+   * Apply any filters set in the schema for autocomplete fields
+   *
+   * In order for this to work, the `$fieldName` param needs to be in
+   * the format `EntityName.field_name`. Anything not in that format
+   * will be ignored, with the expectation that any extension making up
+   * its own notation for identifying fields (e.g. Afform) can implement
+   * its own `PrepareEvent` handler to do filtering. If their callback
+   * runs earlier than this one, it can optionally `setFieldName` to the
+   * standard recognized here to get the benefit of both custom filters
+   * and the ones from the schema.
+   * @see \Civi\Api4\Subscriber\AfformAutocompleteSubscriber::processAfformAutocomplete
+   *
+   * @param \Civi\API\Event\PrepareEvent $event
+   */
+  public function onApiPrepare(\Civi\API\Event\PrepareEvent $event): void {
+    $apiRequest = $event->getApiRequest();
+    if (is_object($apiRequest) && is_a($apiRequest, 'Civi\Api4\Generic\AutocompleteAction')) {
+      [$entityName, $fieldName] = array_pad(explode('.', (string) $apiRequest->getFieldName(), 2), 2, '');
+
+      if (!$fieldName) {
+        return;
+      }
+      try {
+        $fieldSpec = civicrm_api4($entityName, 'getFields', [
+          'checkPermissions' => FALSE,
+          'where' => [['name', '=', $fieldName]],
+        ])->single();
+
+        // Auto-add filters defined in schema
+        foreach ($fieldSpec['input_attrs']['filter'] ?? [] as $key => $value) {
+          $apiRequest->addFilter($key, $value);
+        }
+
+      }
+      catch (\Exception $e) {
+        // Ignore anything else. Extension using their own $fieldName notation can do their own handling.
+      }
+    }
+  }
+
+}
diff --git a/ang/crmUi.js b/ang/crmUi.js
index 97e74027053407d04f38d0f60dd2498759329f87..d06de24e8a6d20d5b0df7286848edb83cea37ffa 100644
--- a/ang/crmUi.js
+++ b/ang/crmUi.js
@@ -716,28 +716,72 @@
     .directive('crmAutocomplete', function () {
       return {
         require: {
+          crmAutocomplete: 'crmAutocomplete',
           ngModel: '?ngModel'
         },
+        priority: 100,
         bindToController: {
-          crmAutocomplete: '<',
+          entity: '<crmAutocomplete',
           crmAutocompleteParams: '<',
           multi: '<',
-          autoOpen: '<'
+          autoOpen: '<',
+          staticOptions: '<'
+        },
+        link: function(scope, element, attr, ctrl) {
+          // Copied from ng-list but applied conditionally if field is multi-valued
+          var parseList = function(viewValue) {
+            // If the viewValue is invalid (say required but empty) it will be `undefined`
+            if (_.isUndefined(viewValue)) return;
+
+            if (!ctrl.crmAutocomplete.multi) {
+              return viewValue;
+            }
+
+            var list = [];
+
+            if (viewValue) {
+              _.each(viewValue.split(','), function(value) {
+                if (value) {
+                  list.push(_.trim(value));
+                }
+              });
+            }
+
+            return list;
+          };
+
+          if (ctrl.ngModel) {
+            // Ensure widget is updated when model changes
+            ctrl.ngModel.$render = function() {
+              element.val(ctrl.ngModel.$viewValue || '').change();
+            };
+
+            // Copied from ng-list
+            ctrl.ngModel.$parsers.push(parseList);
+            ctrl.ngModel.$formatters.push(function(value) {
+              return _.isArray(value) ? value.join(',') : value;
+            });
+
+            // Copied from ng-list
+            ctrl.ngModel.$isEmpty = function(value) {
+              return !value || !value.length;
+            };
+          }
         },
         controller: function($element, $timeout) {
           var ctrl = this;
-          $timeout(function() {
-            $element.crmAutocomplete(ctrl.crmAutocomplete, ctrl.crmAutocompleteParams, {
-              multiple: ctrl.multi,
-              minimumInputLength: ctrl.autoOpen ? 0 : 1
+
+          // Intitialize widget, and re-render it every time params change
+          this.$onChanges = function() {
+            // Timeout is to wait for `placeholder="{{ ts(...) }}"` to be resolved
+            $timeout(function() {
+              $element.crmAutocomplete(ctrl.entity, ctrl.crmAutocompleteParams, {
+                multiple: ctrl.multi,
+                minimumInputLength: ctrl.autoOpen && !ctrl.staticOptions ? 0 : 1,
+                static: ctrl.staticOptions || [],
+              });
             });
-            // Ensure widget is updated when model changes
-            if (ctrl.ngModel) {
-              ctrl.ngModel.$render = function() {
-                $element.val(ctrl.ngModel.$viewValue || '').change();
-              };
-            }
-          });
+          };
         }
       };
     })
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js
index ba3a108592d95d07d890496b8200829faf5788f4..fae80a96897e4ab806cd75cb72784659f80f6d4f 100644
--- a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js
+++ b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js
@@ -38,7 +38,11 @@
               _.each(ctrl.editor.getEntities({type: field.fk_entity}), function(entity) {
                  options.push({id: entity.name, label: entity.label, icon: afGui.meta.entities[entity.type].icon});
               });
-              $el.crmEntityRef({entity: field.fk_entity, select: {multiple: multi}, static: options});
+              $el.crmAutocomplete(field.fk_entity, {fieldName: field.entity + '.' + field.name}, {
+                multiple: multi,
+                "static": options,
+                minimumInputLength: options.length ? 1 : 0
+              });
             } else if (field.options) {
               options = _.transform(field.options, function(options, val) {
                 options.push({id: val.id, text: val.label});
diff --git a/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php b/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php
index 222ff8ebb348770022194fa79fe86b680ea2079a..c94d392e50190feac60e35ef5124cced717be5a2 100644
--- a/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php
+++ b/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php
@@ -13,7 +13,6 @@ namespace Civi\Api4\Subscriber;
 
 use Civi\Core\Service\AutoService;
 use Civi\Afform\FormDataModel;
-use Civi\API\Events;
 use Civi\Api4\Afform;
 use Civi\Api4\Generic\AutocompleteAction;
 use Civi\Api4\Utils\CoreUtil;
@@ -31,7 +30,7 @@ class AfformAutocompleteSubscriber extends AutoService implements EventSubscribe
    */
   public static function getSubscribedEvents() {
     return [
-      'civi.api.prepare' => ['onApiPrepare', Events::W_MIDDLE],
+      'civi.api.prepare' => ['onApiPrepare', -20],
     ];
   }
 
@@ -90,15 +89,10 @@ class AfformAutocompleteSubscriber extends AutoService implements EventSubscribe
       $isId = $fieldName === CoreUtil::getIdFieldName($apiEntity);
       $formField = $entity['fields'][$fieldName]['defn'] ?? [];
     }
-    $fieldSpec = civicrm_api4($apiEntity, 'getFields', [
-      'checkPermissions' => FALSE,
-      'where' => [['name', '=', $fieldName]],
-    ])->first();
 
-    // Auto-add filters defined in schema
-    foreach ($fieldSpec['input_attrs']['filter'] ?? [] as $key => $value) {
-      $apiRequest->addFilter($key, $value);
-    }
+    // Set standard fieldName so core AutocompleteFieldSubscriber can handle filters from the schema
+    // @see \Civi\Api4\Event\Subscriber\AutocompleteFieldSubscriber::onApiPrepare
+    $apiRequest->setFieldName("$apiEntity.$fieldName");
 
     // For the "Existing Entity" selector,
     // Look up the "type" fields (e.g. contact_type, activity_type_id, case_type_id, etc)
diff --git a/ext/afform/core/ang/af/afField.component.js b/ext/afform/core/ang/af/afField.component.js
index 4de910d9ff289bdc050fb009485122b2f0eb9b84..8b5468d8934fd47b354978df006f7ba7bcaca0c5 100644
--- a/ext/afform/core/ang/af/afField.component.js
+++ b/ext/afform/core/ang/af/afField.component.js
@@ -236,16 +236,9 @@
           else if (ctrl.defn.search_range) {
             return ($scope.dataProvider.getFieldData()[ctrl.fieldName]['>='] = val);
           }
-          // A multi-select needs to split string value into an array
-          if (ctrl.defn.input_attrs && ctrl.defn.input_attrs.multiple) {
-            val = val ? val.split(',') : [];
-          }
           return ($scope.dataProvider.getFieldData()[ctrl.fieldName] = val);
         }
         // Getter
-        if (_.isArray(currentVal)) {
-          return currentVal.join(',');
-        }
         if (ctrl.defn.is_date) {
           return _.isPlainObject(currentVal) ? '{}' : currentVal;
         }
diff --git a/ext/afform/core/ang/af/fields/Select.html b/ext/afform/core/ang/af/fields/Select.html
index c7edf4850a46ceb2a65626a4defb73aaf864ab40..763ad1051146f3cf723032ce236ba568e7412be9 100644
--- a/ext/afform/core/ang/af/fields/Select.html
+++ b/ext/afform/core/ang/af/fields/Select.html
@@ -1,5 +1,6 @@
 <div class="{{:: $ctrl.defn.search_range ? 'form-inline' : 'form-group' }}">
-  <input class="form-control" id="{{:: fieldId }}" crm-ui-select="{data: select2Options, multiple: $ctrl.defn.input_attrs.multiple, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" >
+  <input class="form-control" id="{{:: fieldId }}" ng-if="!$ctrl.defn.input_attrs.multiple" crm-ui-select="{data: select2Options, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" >
+  <input class="form-control" id="{{:: fieldId }}" ng-if="$ctrl.defn.input_attrs.multiple" ng-list crm-ui-select="{data: select2Options, multiple: true, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" >
   <input class="form-control" ng-if=":: $ctrl.defn.search_range && !$ctrl.defn.is_date" id="{{:: fieldId }}2" crm-ui-select="{data: select2Options, placeholder: $ctrl.defn.input_attrs.placeholder2 || $ctrl.defn.input_attrs.placeholder}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['<=']" >
   <div ng-if="$ctrl.defn.search_range && $ctrl.defn.is_date && getSetSelect() === '{}'" class="form-group" ng-include="'~/af/fields/Date.html'"></div>
 </div>
diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js
index 5cc68b77d209e8fb0124f8e28f909e17eed662f6..9419d338d3d37ce0e1158a8ffb462141ff7726fe 100644
--- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js
+++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js
@@ -17,7 +17,6 @@
         var rendered = false,
           field = this.field || {};
         ctrl.dateRanges = CRM.crmSearchTasks.dateRanges;
-        ctrl.entity = field.fk_entity || field.entity;
 
         this.ngModel.$render = function() {
           ctrl.value = ctrl.ngModel.$viewValue;
@@ -46,6 +45,19 @@
         }
       };
 
+      this.getFkEntity = function() {
+        return ctrl.field ? ctrl.field.fk_entity || ctrl.field.entity : null;
+      };
+
+      var autocompleteStaticOptions = {
+        Contact: ['user_contact_id'],
+        '': []
+      };
+
+      this.getAutocompleteStaticOptions = function() {
+        return autocompleteStaticOptions[ctrl.getFkEntity() || ''] || autocompleteStaticOptions[''];
+      };
+
       this.isMulti = function() {
         // If there's a search operator, return `true` if the operator takes multiple values, else `false`
         if (ctrl.op) {
diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html
index eabd9d72b972039b7647c5b6893f691acee02c9e..0574685b8ecae768e042af082db8b03110ff2f40 100644
--- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html
+++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html
@@ -1,6 +1,10 @@
-<div class="form-group" ng-if="!$ctrl.isMulti()">
-  <input class="form-control" ng-model="$ctrl.value" crm-entityref="{entity: $ctrl.entity, select:{allowClear: true, placeholder: ts('Select')}, static: $ctrl.entity === 'Contact' ? ['user_contact_id'] : []}">
-</div>
-<div class="form-group" ng-if="$ctrl.isMulti()">
-  <input class="form-control" ng-model="$ctrl.value" crm-entityref="{entity: $ctrl.entity, select: {multiple: true}, static: $ctrl.entity === 'Contact' ? ['user_contact_id'] : []}" ng-list >
-</div>
+<input
+  class="form-control"
+  ng-model="$ctrl.value"
+  crm-autocomplete="$ctrl.getFkEntity()"
+  crm-autocomplete-params="{fieldName: $ctrl.field.entity + '.' + $ctrl.field.name}"
+  auto-open="true"
+  multi="$ctrl.isMulti()"
+  static-options="$ctrl.getAutocompleteStaticOptions()"
+  placeholder="{{:: ts('Select') }}"
+>
diff --git a/js/Common.js b/js/Common.js
index 2b13cd552a06c0757ef3b7a102facd34e5ccdc75..dcabd0af5cb9ed34291821a8cb03df93fa93c7e4 100644
--- a/js/Common.js
+++ b/js/Common.js
@@ -478,6 +478,7 @@ if (!CRM.vars) CRM.vars = {};
       }
 
       $el
+        .off('.crmSelect2')
         .on('select2-loaded.crmSelect2', function() {
           // Use description as title for each option
           $('.crm-select2-row-description', '#select2-drop').each(function() {
@@ -523,11 +524,43 @@ if (!CRM.vars) CRM.vars = {};
     });
   };
 
+  function getStaticOptions(staticItems) {
+    var staticPresets = {
+      user_contact_id: {
+        id: 'user_contact_id',
+        label: ts('Select Current User'),
+        icon: 'fa-user-circle-o'
+      }
+    };
+
+    return _.transform(staticItems || [], function(staticItems, option) {
+      staticItems.push(_.isString(option) ? staticPresets[option] : option);
+    });
+  }
+
+  function getStaticOptionMarkup(staticItems) {
+    if (!staticItems.length) {
+      return '';
+    }
+    var markup = '<div class="crm-entityref-links crm-entityref-links-static">';
+    _.each(staticItems, function(link) {
+      markup += ' <a class="crm-hover-button" href="#' + link.id + '">' +
+        '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' +
+        _.escape(link.label) + '</a>';
+    });
+    markup += '</div>';
+    return markup;
+  }
+
   // Autocomplete based on APIv4 and Select2.
   $.fn.crmAutocomplete = function(entityName, apiParams, select2Options) {
     select2Options = select2Options || {};
     return $(this).each(function() {
-      $(this).crmSelect2(_.extend({
+      var $el = $(this).off('.crmEntity'),
+        staticItems = getStaticOptions(select2Options.static),
+        multiple = !!select2Options.multiple;
+
+      $el.crmSelect2(_.extend({
         ajax: {
           quietMillis: 250,
           url: CRM.url('civicrm/ajax/api4/' + entityName + '/autocomplete'),
@@ -549,18 +582,51 @@ if (!CRM.vars) CRM.vars = {};
         formatSelection: formatEntityRefSelection,
         escapeMarkup: _.identity,
         initSelection: function($el, callback) {
-          var
-            multiple = !!select2Options.multiple,
-            val = $el.val();
+          var val = $el.val();
           if (val === '') {
             return;
           }
-          var params = $.extend({}, apiParams || {}, {ids: val.split(',')});
-          CRM.api4(entityName, 'autocomplete', params).then(function(result) {
-            callback(multiple ? result : result[0]);
-          });
+          var idsNeeded = _.difference(val.split(','), _.pluck(staticItems, 'id')),
+            existing = _.filter(staticItems, function(item) {
+              return _.includes(val.split(','), item.id);
+            });
+          // If we already have the data, just return it
+          if (!idsNeeded.length) {
+            callback(multiple ? existing : existing[0]);
+          } else {
+            var params = $.extend({}, apiParams || {}, {ids: idsNeeded});
+            CRM.api4(entityName, 'autocomplete', params).then(function (result) {
+              callback(multiple ? result.concat(existing) : result[0]);
+            });
+          }
+        },
+        formatInputTooShort: function() {
+          var txt = $.fn.select2.defaults.formatInputTooShort.call(this);
+          txt += getStaticOptionMarkup(staticItems);
+          return txt;
         }
       }, select2Options));
+
+      $el.on('select2-open.crmEntity', function() {
+        var $el = $(this);
+        $('#select2-drop')
+          .off('.crmEntity')
+          .on('click.crmEntity', '.crm-entityref-links-static a', function(e) {
+            var id = $(this).attr('href').substr(1),
+              item = _.findWhere(staticItems, {id: id});
+            $el.select2('close');
+            if (multiple) {
+              var selection = $el.select2('data');
+              if (!_.findWhere(selection, {id: id})) {
+                selection.push(item);
+                $el.select2('data', selection, true);
+              }
+            } else {
+              $el.select2('data', item, true);
+            }
+            return false;
+          });
+      });
     });
   };
 
@@ -584,14 +650,7 @@ if (!CRM.vars) CRM.vars = {};
       var
         $el = $(this).off('.crmEntity'),
         entity = options.entity || $el.data('api-entity') || 'Contact',
-        selectParams = {},
-        staticPresets = {
-          user_contact_id: {
-            id: 'user_contact_id',
-            label: ts('Select Current User'),
-            icon: 'fa-user-circle-o'
-          }
-        };
+        selectParams = {};
       // Legacy: fix entity name if passed in as snake case
       if (entity.charAt(0).toUpperCase() !== entity.charAt(0)) {
         entity = _.capitalize(_.camelCase(entity));
@@ -600,26 +659,6 @@ if (!CRM.vars) CRM.vars = {};
       $el.data('select-params', $.extend({}, $el.data('select-params') || {}, options.select));
       $el.data('api-params', $.extend(true, {}, $el.data('api-params') || {}, options.api));
       $el.data('create-links', options.create || $el.data('create-links'));
-      var staticItems = options.static || $el.data('static') || [];
-      _.each(staticItems, function(option, i) {
-        if (_.isString(option)) {
-          staticItems[i] = staticPresets[option];
-        }
-      });
-
-      function staticItemMarkup() {
-        if (!staticItems.length) {
-          return '';
-        }
-        var markup = '<div class="crm-entityref-links crm-entityref-links-static">';
-        _.each(staticItems, function(link) {
-          markup += ' <a class="crm-hover-button" href="#' + link.id + '">' +
-            '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' +
-            _.escape(link.label) + '</a>';
-        });
-        markup += '</div>';
-        return markup;
-      }
 
       $el.addClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref');
       var settings = {
@@ -649,7 +688,7 @@ if (!CRM.vars) CRM.vars = {};
           var
             multiple = !!$el.data('select-params').multiple,
             val = $el.val(),
-            stored = ($el.data('entity-value') || []).concat(staticItems);
+            stored = $el.data('entity-value') || [];
           if (val === '') {
             return;
           }
@@ -704,7 +743,7 @@ if (!CRM.vars) CRM.vars = {};
       else {
         selectParams.formatInputTooShort = function() {
           var txt = $el.data('select-params').formatInputTooShort || $.fn.select2.defaults.formatInputTooShort.call(this);
-          txt += entityRefFiltersMarkup($el) + staticItemMarkup() + renderEntityRefCreateLinks($el);
+          txt += entityRefFiltersMarkup($el) + renderEntityRefCreateLinks($el);
           return txt;
         };
         selectParams.formatNoMatches = function() {
@@ -739,21 +778,6 @@ if (!CRM.vars) CRM.vars = {};
               });
               return false;
             })
-            .on('click.crmEntity', '.crm-entityref-links-static a', function(e) {
-              var id = $(this).attr('href').substr(1),
-                item = _.findWhere(staticItems, {id: id});
-              $el.select2('close');
-              if ($el.select2('container').hasClass('select2-container-multi')) {
-                var selection = $el.select2('data');
-                if (!_.findWhere(selection, {id: id})) {
-                  selection.push(item);
-                  $el.select2('data', selection, true);
-                }
-              } else {
-                $el.select2('data', item, true);
-              }
-              return false;
-            })
             .on('change.crmEntity', '.crm-entityref-filter-value', function() {
               var filter = $el.data('user-filter') || {};
               filter.value = $(this).val();