Show "Loading..." while the autocomplete query is loading.

Some autocomplete queries can take multiple seconds to complete, during
which we show no clear indication that anything is being done. With this
change we always show "Loading..." status.

We also introduce a more robust way of telling if the query is outdated.
The main goal is to simplify the logic to keep track of identifying
irrelevant queries, because of edits or cancelation. The similar result
can be achieved by examining text and queryStatus, but it's harder to
reason about.

Before: https://screencast.googleplex.com/cast/NTAyMjY5MTczNzIwNjc4NHxiZDM3ZTAzMS0xYg
After: https://screencast.googleplex.com/cast/NTUzNjQwNTA5MTUxNjQxNnw1ZmUyYjkwMy1hNQ

Release-Notes: skip
Google-Bug-Id: b/266681311
Change-Id: I99036e345ff8923c54c4862372c293ec364cd132
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 91e601c..661ffa0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -35,6 +35,16 @@
   selected: HTMLElement | null;
 }
 
+export enum AutocompleteQueryStatusType {
+  LOADING = 'loading',
+  ERROR = 'error',
+}
+
+export interface AutocompleteQueryStatus {
+  type: AutocompleteQueryStatusType;
+  message: string;
+}
+
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends LitElement {
   /**
@@ -58,8 +68,8 @@
   /** If specified a single non-interactable line is shown instead of
    * suggestions.
    */
-  @property({type: String})
-  errorMessage?: String;
+  @property({type: Object})
+  queryStatus?: AutocompleteQueryStatus;
 
   @property({type: Number})
   verticalOffset = 0;
@@ -117,10 +127,12 @@
         li.selected {
           background-color: var(--hover-background-color);
         }
-        li.query-error {
+        li.query-status {
           background-color: var(--disabled-background);
-          color: var(--error-foreground);
           cursor: default;
+        }
+        li.query-status.error {
+          color: var(--error-foreground);
           white-space: pre-wrap;
         }
         @media only screen and (max-height: 35em) {
@@ -140,7 +152,7 @@
   }
 
   private isSuggestionListInteractible() {
-    return !this.isHidden && !this.errorMessage;
+    return !this.isHidden && !this.queryStatus;
   }
 
   constructor() {
@@ -172,7 +184,8 @@
   override updated(changedProperties: PropertyValues) {
     if (
       changedProperties.has('suggestions') ||
-      changedProperties.has('isHidden')
+      changedProperties.has('isHidden') ||
+      changedProperties.has('queryStatus')
     ) {
       if (!this.isHidden) {
         this.computeCursorStopsAndRefit();
@@ -180,15 +193,19 @@
     }
   }
 
-  private renderError() {
+  private renderStatus() {
     return html`
       <li
         tabindex="-1"
-        aria-label="autocomplete query error"
-        class="query-error"
+        aria-label="autocomplete query status"
+        class="query-status ${this.queryStatus?.type}"
       >
-        <span>${this.errorMessage}</span>
-        <span class="label">ERROR</span>
+        <span>${this.queryStatus?.message}</span>
+        <span class="label"
+          >${this.queryStatus?.type === AutocompleteQueryStatusType.ERROR
+            ? 'ERROR'
+            : ''}</span
+        >
       </li>
     `;
   }
@@ -198,8 +215,8 @@
       <div class="dropdown-content" id="suggestions" role="listbox">
         <ul>
           ${when(
-            this.errorMessage,
-            () => this.renderError(),
+            this.queryStatus,
+            () => this.renderStatus(),
             () => html`
               ${repeat(
                 this.suggestions,
@@ -236,7 +253,7 @@
   }
 
   getCurrentText() {
-    if (!this.errorMessage) {
+    if (!this.queryStatus) {
       return this.getCursorTarget()?.dataset['value'] || '';
     }
     return '';
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 54d054b..10ba5d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -5,7 +5,10 @@
  */
 import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
-import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from './gr-autocomplete-dropdown';
 import {
   pressKey,
   queryAll,
@@ -177,7 +180,7 @@
     });
   });
 
-  suite('error tests', () => {
+  suite('status tests', () => {
     let element: GrAutocompleteDropdown;
 
     setup(async () => {
@@ -185,7 +188,10 @@
         html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
       );
       element.open();
-      element.errorMessage = 'Failed query error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Failed query error',
+      };
       await waitEventLoop();
     });
 
@@ -200,8 +206,8 @@
           <div class="dropdown-content" id="suggestions" role="listbox">
             <ul>
               <li
-                aria-label="autocomplete query error"
-                class="query-error"
+                aria-label="autocomplete query status"
+                class="query-status error"
                 tabindex="-1"
               >
                 <span>Failed query error</span>
@@ -213,6 +219,31 @@
       );
     });
 
+    test('renders loading', async () => {
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.LOADING,
+        message: 'Loading...',
+      };
+      await waitEventLoop();
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query status"
+                class="query-status loading"
+                tabindex="-1"
+              >
+                <span>Loading...</span>
+                <span class="label"></span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
+
     test('escape key close dropdown with error', async () => {
       const closeSpy = sinon.spy(element, 'close');
       pressKey(element, Key.ESC);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index f8f7f9d..8711f14 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -7,7 +7,11 @@
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatus,
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {fire, fireEvent} from '../../../utils/event-util';
 import {
   debounce,
@@ -166,7 +170,7 @@
 
   @state() suggestions: AutocompleteSuggestion[] = [];
 
-  @state() queryErrorMessage?: string;
+  @state() queryStatus?: AutocompleteQueryStatus;
 
   @state() index: number | null = null;
 
@@ -179,8 +183,23 @@
 
   @state() selected: HTMLElement | null = null;
 
+  /**
+   * The query id that status or suggestions correspond to.
+   */
+  private activeQueryId = 0;
+
+  /**
+   * Last scheduled update suggestions task.
+   */
   private updateSuggestionsTask?: DelayedTask;
 
+  // Generate ids for scheduled suggestion queries to easily distinguish them.
+  private static NEXT_QUERY_ID = 1;
+
+  private static getNextQueryId() {
+    return GrAutocomplete.NEXT_QUERY_ID++;
+  }
+
   /**
    * @return Promise that resolves when suggestions are update.
    */
@@ -266,7 +285,7 @@
     }
     if (
       changedProperties.has('suggestions') ||
-      changedProperties.has('queryErrorMessage')
+      changedProperties.has('queryStatus')
     ) {
       this.updateDropdownVisibility();
     }
@@ -310,7 +329,7 @@
         @item-selected=${this.handleItemSelect}
         @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
-        .errorMessage=${this.queryErrorMessage}
+        .queryStatus=${this.queryStatus}
         role="listbox"
         .index=${this.index}
       >
@@ -412,8 +431,7 @@
     // Reset suggestions for every update
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
-    this.suggestions = [];
-    this.queryErrorMessage = undefined;
+    this.resetQueryOutput();
 
     // TODO(taoalpha): Also skip if text has not changed
 
@@ -421,8 +439,7 @@
       return;
     }
 
-    const query = this.query;
-    if (!query) {
+    if (!this.query) {
       return;
     }
 
@@ -435,50 +452,69 @@
       return;
     }
 
-    const requestText = this.text;
-    const update = () => {
-      query(this.text)
-        .then(suggestions => {
-          if (requestText !== this.text) {
-            // Late response.
-            return;
-          }
-          for (const suggestion of suggestions) {
-            suggestion.text = suggestion?.name ?? '';
-          }
-          this.suggestions = suggestions;
-          if (this.index === -1) {
-            this.value = '';
-          }
-        })
-        .catch(e => {
-          this.value = '';
-          if (typeof e === 'string') {
-            this.queryErrorMessage = e;
-          } else if (e instanceof Error) {
-            this.queryErrorMessage = e.message;
-          }
-        });
-    };
-
+    const queryId = GrAutocomplete.getNextQueryId();
+    this.activeQueryId = queryId;
+    this.setQueryStatus({
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
     this.updateSuggestionsTask = debounce(
       this.updateSuggestionsTask,
-      update,
+      this.createUpdateTask(queryId, this.query, this.text),
       DEBOUNCE_WAIT_MS
     );
   }
 
+  private createUpdateTask(
+    queryId: number,
+    query: AutocompleteQuery,
+    text: string
+  ): () => Promise<void> {
+    return async () => {
+      let suggestions: AutocompleteSuggestion[];
+      try {
+        suggestions = await query(text);
+      } catch (e) {
+        this.value = '';
+        if (typeof e === 'string') {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e,
+          });
+        } else if (e instanceof Error) {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e.message,
+          });
+        }
+        return;
+      }
+      if (queryId !== this.activeQueryId) {
+        // Late response.
+        return;
+      }
+      for (const suggestion of suggestions) {
+        suggestion.text = suggestion?.name ?? '';
+      }
+      this.setSuggestions(suggestions);
+      if (this.index === -1) {
+        this.value = '';
+      }
+    };
+  }
+
   setFocus(focused: boolean) {
     if (focused === this.focused) return;
     this.focused = focused;
     this.updateDropdownVisibility();
   }
 
+  private shouldShowDropdown() {
+    return (this.suggestions.length > 0 || this.queryStatus) && this.focused;
+  }
+
   updateDropdownVisibility() {
-    if (
-      (this.suggestions.length > 0 || this.queryErrorMessage) &&
-      this.focused
-    ) {
+    if (this.shouldShowDropdown()) {
       this.suggestionsDropdown?.open();
       return;
     }
@@ -513,8 +549,8 @@
       case 'Tab':
         if (this.suggestions.length > 0 && this.tabComplete) {
           e.preventDefault();
-          this.focus();
           this.handleInputCommit(true);
+          this.focus();
         } else {
           this.setFocus(false);
         }
@@ -541,7 +577,8 @@
         // been based on a previous input. Clear them. This prevents an
         // outdated suggestion from being used if the input keystroke is
         // immediately followed by a commit keystroke. @see Issue 8655
-        this.suggestions = [];
+        this.resetQueryOutput();
+        this.activeQueryId = 0;
     }
     this.dispatchEvent(
       new CustomEvent('input-keydown', {
@@ -553,9 +590,11 @@
   }
 
   cancel() {
-    if (this.suggestions.length || this.queryErrorMessage) {
-      this.suggestions = [];
-      this.queryErrorMessage = undefined;
+    if (this.shouldShowDropdown()) {
+      this.resetQueryOutput();
+      // If query is in flight by setting id to 0 we indicate that the results
+      // are outdated.
+      this.activeQueryId = 0;
       this.requestUpdate();
     } else {
       fireEvent(this, 'cancel');
@@ -566,7 +605,7 @@
     // Nothing to do if no suggestions.
     if (
       !this.allowNonSuggestedValues &&
-      (this.suggestionsDropdown?.isHidden || this.queryErrorMessage)
+      (this.suggestionsDropdown?.isHidden || this.suggestions.length === 0)
     ) {
       return;
     }
@@ -608,6 +647,7 @@
       }
     }
     this.setFocus(false);
+    this.activeQueryId = 0;
   };
 
   /**
@@ -644,8 +684,7 @@
       }
     }
 
-    this.suggestions = [];
-    this.queryErrorMessage = undefined;
+    this.resetQueryOutput();
     // we need willUpdate to send text-changed event before we can send the
     // 'commit' event
     await this.updateComplete;
@@ -659,6 +698,23 @@
       );
     }
   }
+
+  // resetQueryOutput, setSuggestions and setQueryStatus insure that suggestions
+  // and queryStatus are never set at the same time.
+  private resetQueryOutput() {
+    this.suggestions = [];
+    this.queryStatus = undefined;
+  }
+
+  private setSuggestions(suggestions: AutocompleteSuggestion[]) {
+    this.suggestions = suggestions;
+    this.queryStatus = undefined;
+  }
+
+  private setQueryStatus(queryStatus: AutocompleteQueryStatus) {
+    this.suggestions = [];
+    this.queryStatus = queryStatus;
+  }
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index 81949c7..a3da953 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -8,11 +8,15 @@
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
 import {
   assertFails,
+  mockPromise,
   pressKey,
   queryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html, assert} from '@open-wc/testing';
 import {Key, Modifier} from '../../../utils/dom-util';
@@ -30,9 +34,7 @@
   const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
 
   setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete no-debounce></gr-autocomplete>`
-    );
+    element = await fixture(html`<gr-autocomplete></gr-autocomplete>`);
   });
 
   test('renders', () => {
@@ -151,7 +153,10 @@
         ],
       }
     );
-    assert.equal(element.suggestionsDropdown?.errorMessage, 'blah not allowed');
+    assert.equal(
+      element.suggestionsDropdown?.queryStatus?.message,
+      'blah not allowed'
+    );
   });
 
   test('cursor starts on suggestions', async () => {
@@ -240,17 +245,21 @@
     await element.updateComplete;
 
     return assertFails(promise).then(async () => {
+      await element.latestSuggestionUpdateComplete;
       await waitUntil(() => !suggestionsEl().isHidden);
 
       const cancelHandler = sinon.spy();
       element.addEventListener('cancel', cancelHandler);
-      assert.equal(element.queryErrorMessage, 'Test error');
+      assert.deepEqual(element.queryStatus, {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Test error',
+      });
 
       pressKey(inputEl(), Key.ESC);
       await waitUntil(() => suggestionsEl().isHidden);
 
       assert.isFalse(cancelHandler.called);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
 
       pressKey(inputEl(), Key.ESC);
       await element.updateComplete;
@@ -260,16 +269,14 @@
   });
 
   test('emits commit and handles cursor movement', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
-    const queryStub = sinon.spy(
-      (input: string) =>
-        (promise = Promise.resolve([
-          {name: input + ' 0', value: '0'},
-          {name: input + ' 1', value: '1'},
-          {name: input + ' 2', value: '2'},
-          {name: input + ' 3', value: '3'},
-          {name: input + ' 4', value: '4'},
-        ] as AutocompleteSuggestion[]))
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
     );
     element.query = queryStub;
     await element.updateComplete;
@@ -280,7 +287,7 @@
     element.text = 'blah';
     await element.updateComplete;
 
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => !suggestionsEl().isHidden);
 
       const commitHandler = sinon.spy();
@@ -456,24 +463,22 @@
   });
 
   test('suggestions should not carry over', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
-      .returns(
-        (promise = Promise.resolve([
-          {name: 'suggestion', value: '0'},
-        ] as AutocompleteSuggestion[]))
-      );
+      .resolves([{name: 'suggestion', value: '0'}] as AutocompleteSuggestion[]);
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
     await element.updateComplete;
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => element.suggestions.length > 0);
       assert.equal(element.suggestions.length, 1);
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
       element.text = '';
       element.threshold = 0;
       await element.updateComplete;
+      await element.latestSuggestionUpdateComplete;
       assert.equal(element.suggestions.length, 0);
     });
   });
@@ -488,11 +493,15 @@
     element.text = 'bla';
     await element.updateComplete;
     return assertFails(promise).then(async () => {
-      await waitUntil(() => element.queryErrorMessage === 'Test error');
+      await element.latestSuggestionUpdateComplete;
+      await waitUntil(() => element.queryStatus?.message === 'Test error');
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
       element.text = '';
       element.threshold = 0;
       await element.updateComplete;
-      assert.isUndefined(element.queryErrorMessage);
+      await element.latestSuggestionUpdateComplete;
+      assert.isUndefined(element.queryStatus);
     });
   });
 
@@ -514,6 +523,7 @@
     return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
+      await element.latestSuggestionUpdateComplete;
       await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
       pressKey(inputEl(), Key.ENTER);
@@ -524,15 +534,24 @@
   });
 
   test('tabComplete flag functions', async () => {
+    element.query = sinon
+      .stub()
+      .resolves([
+        {name: 'tunnel snakes rule!', value: 'snakes'},
+      ] as AutocompleteSuggestion[]);
+
     // commitHandler checks for the commit event, whereas commitSpy checks for
     // the _commit function of the element.
     const commitHandler = sinon.spy();
     element.addEventListener('commit', commitHandler);
     const commitSpy = sinon.spy(element, '_commit');
     element.setFocus(true);
-
-    element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
+    element.text = 'text1';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
     await element.updateComplete;
 
@@ -540,9 +559,12 @@
     assert.isFalse(commitSpy.called);
     assert.isFalse(element.focused);
 
-    element.tabComplete = true;
-    await element.updateComplete;
     element.setFocus(true);
+    element.tabComplete = true;
+    element.text = 'text2';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
     await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
 
@@ -597,7 +619,11 @@
       ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
-      element.queryErrorMessage = 'Error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
       element.handleInputCommit();
       assert.isFalse(commitStub.called);
     }
@@ -620,7 +646,11 @@
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
-      element.queryErrorMessage = 'Error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
       element.handleInputCommit();
       assert.isTrue(commitStub.called);
     }
@@ -629,6 +659,7 @@
   test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
+    element.suggestions = [{name: 'first suggestion'}];
     element.handleInputCommit();
     assert.isTrue(commitStub.calledOnce);
   });
@@ -671,6 +702,79 @@
     assert.equal(element.text, 'file:x');
   });
 
+  test('render loading replace with suggestions when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 1);
+    assert.isUndefined(element.queryStatus);
+  });
+
+  test('render loading replace with error when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.reject(new Error('Test error'));
+    await assertFails(queryPromise);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.ERROR,
+      message: 'Test error',
+    });
+  });
+
+  test('render loading esc cancels', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    pressKey(inputEl(), Key.ESC);
+    await waitUntil(() => suggestionsEl().isHidden);
+
+    assert.isFalse(cancelHandler.called);
+    assert.isUndefined(element.queryStatus);
+
+    pressKey(inputEl(), Key.ESC);
+    await element.updateComplete;
+
+    assert.isTrue(cancelHandler.called);
+  });
+
   suite('focus', () => {
     let commitSpy: sinon.SinonSpy;
     let focusSpy: sinon.SinonSpy;
@@ -688,13 +792,16 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
     test('enter in input does not re-render error', async () => {
       element.allowNonSuggestedValues = true;
-      element.queryErrorMessage = 'Error message';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error message',
+      };
 
       pressKey(inputEl(), Key.ENTER);
 
@@ -702,7 +809,7 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index d916118..3bb058e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -295,7 +295,10 @@
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
 
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
 
       pressKey(autocomplete.input!, Key.ENTER);
 
@@ -312,7 +315,11 @@
     test('autocomplete suggestions closed enter saves suggestion', async () => {
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
+
       // Press enter to close suggestions.
       pressKey(autocomplete.input!, Key.ENTER);
 
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 752de62..905b16c 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -62,6 +62,8 @@
   /**
    * Promise that is resolved after the callback is run or the task is
    * cancelled.
+   *
+   * If callback returns a Promise this resolves after the promise is settled.
    */
   public readonly promise: Promise<ResolvedDelayedTaskStatus>;
 
@@ -69,14 +71,25 @@
     value: ResolvedDelayedTaskStatus | PromiseLike<ResolvedDelayedTaskStatus>
   ) => void;
 
-  constructor(private callback: () => void, waitMs = 0) {
+  private callCallbackAndResolveOnCompletion() {
+    let callbackResult;
+    if (this.callback) callbackResult = this.callback();
+    if (callbackResult instanceof Promise) {
+      callbackResult.finally(() =>
+        this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED)
+      );
+    } else {
+      this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+    }
+  }
+
+  constructor(private callback: () => void | Promise<void>, waitMs = 0) {
     this.promise = new Promise(resolve => {
       this.resolvePromise = resolve;
       this.timerId = window.setTimeout(() => {
         if (this.timerId) _testOnly_allTasks.delete(this.timerId);
         this.timerId = undefined;
-        if (this.callback) this.callback();
-        resolve(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+        this.callCallbackAndResolveOnCompletion();
       }, waitMs);
       _testOnly_allTasks.set(this.timerId, this);
     });
@@ -98,8 +111,7 @@
   flush() {
     if (this.isActive()) {
       this.cancelTimer();
-      if (this.callback) this.callback();
-      this.resolvePromise?.(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+      this.callCallbackAndResolveOnCompletion();
     }
   }
 
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 9f029b8..ee4f73a 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -6,8 +6,8 @@
 import {assert} from '@open-wc/testing';
 import {SinonFakeTimers} from 'sinon';
 import '../test/common-test-setup';
-import {waitEventLoop} from '../test/test-utils';
-import {asyncForeach, debounceP} from './async-util';
+import {mockPromise, waitEventLoop, waitUntil} from '../test/test-utils';
+import {asyncForeach, debounceP, DelayedTask} from './async-util';
 
 suite('async-util tests', () => {
   suite('asyncForeach', () => {
@@ -205,4 +205,16 @@
       await waitEventLoop();
     });
   });
+
+  test('DelayedTask promise resolved when callback is done', async () => {
+    const callbackPromise = mockPromise<void>();
+    const task = new DelayedTask(() => callbackPromise);
+    let completed = false;
+    task.promise.then(() => (completed = true));
+    await waitUntil(() => !task.isActive());
+
+    assert.isFalse(completed);
+    callbackPromise.resolve();
+    await waitUntil(() => completed);
+  });
 });