Merge changes I8ed34f11,I4aed3276,I4bcdcb50

* changes:
  AttentionSetEmail: Use fromChangeUpdateAndReason to generate messageId
  Move messageId generation into AttentionSetEmail
  AttentionSetEmail: Avoid accessing non-thread-safe vars from bg thread
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 5a7b3cb..506d292 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -100,7 +100,11 @@
         .map(
             id -> {
               try {
-                return changeDataFactory.create(projectName, id);
+                ChangeData cd = changeDataFactory.create(projectName, id);
+                cd.notes(); // Make sure notes are available. This will trigger loading notes and
+                // throw an exception in case the change is corrupt and can't be loaded. It will
+                // then be omitted from the result.
+                return cd;
               } catch (Exception e) {
                 // We drop changes that we can't load. The repositories contain 'dead' change refs
                 // and we want to overall operation to continue.
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index a4ef0c3..9b01735 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -84,16 +84,15 @@
   constructor() {
     super();
     this.query = (input: string) => this.getRepoBranchesSuggestions(input);
-  }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    if (!this.repoName) return;
-
-    subscribe(this, this.configModel().serverConfig$, config => {
-      this.privateChangesEnabled =
-        config?.change?.disable_private_changes ?? false;
-    });
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.privateChangesEnabled =
+          config?.change?.disable_private_changes ?? false;
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 06691d7..fc2b789 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -127,12 +127,16 @@
 
   constructor() {
     super();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
       }
-    });
+    );
   }
 
   override connectedCallback() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 7b14612..596e850 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -14,6 +14,7 @@
 import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
 import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
 import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
 
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
@@ -56,16 +57,16 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChangeNums$,
+      () => this.getBulkActionsModel().selectedChangeNums$,
       selectedChangeNums => (this.numSelected = selectedChangeNums.length)
     );
     subscribe(
       this,
-      this.getBulkActionsModel().totalChangeCount$,
+      () => this.getBulkActionsModel().totalChangeCount$,
       totalChangeCount => (this.totalChangeCount = totalChangeCount)
     );
   }
@@ -111,6 +112,7 @@
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index bc73990..df68f5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -66,6 +66,7 @@
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index 4fd65df..eb5e6a8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -35,11 +35,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => (this.selectedChanges = selectedChanges)
     );
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 07bc31d..77c9382 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -24,6 +24,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {queryAndAssert} from '../../../utils/common-util';
+import '@polymer/iron-icon/iron-icon';
 import {
   LabelNameToValuesMap,
   ReviewInput,
@@ -36,6 +37,7 @@
 import '../../change/gr-label-score-row/gr-label-score-row';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
 import {allSettled} from '../../../utils/async-util';
+import {pluralize} from '../../../utils/string-util';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -77,24 +79,37 @@
           margin-top: var(--spacing-m);
         }
         .vote-type {
-          margin-bottom: var(--spacing-m);
+          margin-bottom: var(--spacing-s);
           margin-top: 0;
           display: table-caption;
-          font-weight: 600; /* TODO: create css variable for it */
         }
         .main-heading {
           margin-bottom: var(--spacing-m);
           font-weight: var(--font-weight-h2);
         }
+        .error-container {
+          background-color: var(--red-50);
+          margin-top: var(--spacing-l);
+        }
+        .error-container iron-icon {
+          padding: 10px var(--spacing-xl);
+          color: var(--red-700);
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        .error-container span {
+          position: relative;
+          top: 1px;
+        }
       `,
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => {
         this.selectedChanges = selectedChanges;
         this.resetFlow();
@@ -102,7 +117,7 @@
     );
     subscribe(
       this,
-      this.userModel.account$,
+      () => this.userModel.account$,
       account => (this.account = account)
     );
   }
@@ -146,20 +161,40 @@
               'Trigger Votes',
               permittedLabels
             )}
+            ${this.renderErrors()}
           </div>
-          <!-- TODO: Add error handling status if something fails -->
         </gr-dialog>
       </gr-overlay>
     `;
   }
 
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <iron-icon icon="gr-icons:error"></iron-icon>
+        <span>
+          <!-- prettier-ignore -->
+          Failed to vote on ${pluralize(
+            Array.from(this.progressByChange.values()).filter(
+              status => status === ProgressStatus.FAILED
+            ).length,
+            'change'
+          )}
+        </span>
+      </div>
+    `;
+  }
+
   private renderLabels(
     labels: Label[],
     heading: string,
     permittedLabels?: LabelNameToValuesMap
   ) {
     return html` <div class="scoresTable newSubmitRequirements">
-      <h3 class="vote-type">${labels.length ? heading : nothing}</h3>
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
       ${labels
         .filter(
           label =>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 0447713..e2cbaf0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -157,7 +157,7 @@
           </div>
           <div slot="main">
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="vote-type">Submit requirements votes</h3>
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
               <gr-label-score-row name="A"> </gr-label-score-row>
               <gr-label-score-row name="B"> </gr-label-score-row>
               <gr-label-score-row name="C"> </gr-label-score-row>
@@ -165,7 +165,7 @@
               </gr-label-score-row>
             </div>
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="vote-type">Trigger Votes</h3>
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
               <gr-label-score-row name="change1OnlyTriggerLabelE">
               </gr-label-score-row>
             </div>
@@ -174,6 +174,75 @@
       </gr-overlay> `);
   });
 
+  test('renders with errors', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.FAILED
+    );
+
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+            <div class="error-container">
+              <iron-icon icon="gr-icons:error"> </iron-icon>
+              <span> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `);
+  });
+
   test('button state updates as changes are updated', async () => {
     const changes: ChangeInfo[] = [change1];
     getChangesStub.returns(Promise.resolve(changes));
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..d7ff864
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,379 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.hashtags?.length),
+                () => this.renderExistingHashtagsMode(),
+                () => this.renderNoExistingHashtagsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtagsMode() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingHashtags.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${applyToAllDisabled}
+            @click=${this.applyHashtagToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-hashtags-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeHashtags}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingHashtagsMode() {
+    const isCreateNewHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getHashtagSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.hashtagToAdd}
+        .query=${(query: string) => this.getHashtagSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type hashtag name to create or filter hashtags"
+        @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+          (this.hashtagToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Creating hashtag...')}
+            .disabled=${isCreateNewHashtagDisabled}
+            >Create new hashtag</gr-button
+          >
+          <gr-button
+            id="apply-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Applying hashtag...')}
+            .disabled=${isApplyHashtagDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      this.openDropdown();
+    }
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private removeHashtags() {
+    this.loadingText = `Removing hashtag${
+      this.selectedExistingHashtags.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.hashtags &&
+            change.hashtags.some(hashtag =>
+              this.selectedExistingHashtags.has(hashtag)
+            )
+        )
+        .map(change =>
+          this.restApiService.setChangeHashtag(change._number, {
+            remove: Array.from(this.selectedExistingHashtags.values()),
+          })
+        )
+    );
+  }
+
+  private applyHashtagToAll() {
+    this.loadingText = 'Applying hashtag to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: Array.from(this.selectedExistingHashtags.values()),
+        })
+      )
+    );
+  }
+
+  private addHashtag(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: [this.hashtagToAdd],
+        })
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<Hashtag[]>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..6910ae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >Hashtag</gr-button
+        >
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          style="outline: none; display: none;"
+          vertical-align="auto"
+          horizontal-align="auto"
+        >
+        </iron-dropdown>
+      `);
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing hashtags', () => {
+    const changesWithHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithHashtags[0]);
+      await selectChange(changesWithHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <span role="button" aria-label="hashtag1" class="chip"
+                  >hashtag1</span
+                >
+                <span role="button" aria-label="hashtag2" class="chip"
+                  >hashtag2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-hashtags-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('remove single hashtag', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledOnce);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1']},
+      ]);
+    });
+
+    test('remove multiple hashtags', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtags...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+    });
+
+    test('can only apply a single hashtag', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies hashtag to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {add: ['hashtag1']},
+      ]);
+    });
+  });
+
+  suite('change have no existing hashtags', () => {
+    const changesWithNoHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithNoHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoHashtags[0]);
+      await selectChange(changesWithNoHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders no-existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new hashtag</gr-button
+                  >
+                  <gr-button
+                    id="apply-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+
+    test('apply hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 92f6f62..933b300 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -125,6 +125,18 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
@@ -134,14 +146,6 @@
           'change-list-item-cell'
         );
       });
-    subscribe(
-      this,
-      this.getBulkActionsModel().selectedChangeNums$,
-      selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
-      }
-    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 58eec3b..24719e8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -24,7 +24,6 @@
 import {
   GrReviewerSuggestionsProvider,
   ReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
@@ -34,15 +33,6 @@
 import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
 import '@polymer/iron-icon/iron-icon';
 
-const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
-  ReviewerState,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES
-> = {
-  REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
-  CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
-  REMOVED: SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY,
-};
-
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
   @state() private selectedChanges: ChangeInfo[] = [];
@@ -80,6 +70,8 @@
 
   private restApiService = getAppContext().restApiService;
 
+  private isLoggedIn = false;
+
   static override get styles() {
     return css`
       gr-dialog {
@@ -116,18 +108,23 @@
     `;
   }
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => (this.selectedChanges = selectedChanges)
     );
     subscribe(
       this,
-      this.getConfigModel().serverConfig$,
+      () => this.getConfigModel().serverConfig$,
       serverConfig => (this.serverConfig = serverConfig)
     );
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
   }
 
   override render() {
@@ -275,7 +272,7 @@
         ProgressStatus.NOT_STARTED,
       ])
     );
-    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
       this.updatedAccountsByReviewerState.set(
         state,
         this.getCurrentAccounts(state)
@@ -396,15 +393,15 @@
   }
 
   private createSuggestionsProvider(
-    state: ReviewerState
+    state: ReviewerState.CC | ReviewerState.REVIEWER
   ): ReviewerSuggestionsProvider {
-    const suggestionsProvider = GrReviewerSuggestionsProvider.create(
+    const suggestionsProvider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      // TODO: fan out and get suggestions allowed by all changes
-      this.selectedChanges[0]._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state]
+      state,
+      this.serverConfig,
+      this.isLoggedIn,
+      ...this.selectedChanges.map(change => change._number)
     );
-    suggestionsProvider.init();
     return suggestionsProvider;
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 2bf6446..330a93b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -132,13 +132,9 @@
   constructor() {
     super();
     provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.bulkActionsModel.selectedChangeNums$,
+      () => this.bulkActionsModel.selectedChangeNums$,
       selectedChanges =>
         (this.showBulkActionsHeader = selectedChanges.length > 0)
     );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index a9c6526..ffdc9f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -21,7 +21,6 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {classMap} from 'lit/directives/class-map';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {pluralize} from '../../../utils/string-util';
 import {ProgressStatus} from '../../../constants/constants';
 import {allSettled} from '../../../utils/async-util';
 
@@ -31,8 +30,6 @@
 
   @state() private topicToAdd: TopicName = '' as TopicName;
 
-  @state() private selectedExistingTopics: Set<TopicName> = new Set();
-
   @state() private existingTopicSuggestions: TopicName[] = [];
 
   @state() private loadingText?: string;
@@ -46,6 +43,8 @@
 
   @query('iron-dropdown') private dropdown?: IronDropdownElement;
 
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
   private getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
   private restApiService = getAppContext().restApiService;
@@ -111,11 +110,11 @@
     ];
   }
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => {
         this.selectedChanges = selectedChanges;
       }
@@ -123,14 +122,19 @@
   }
 
   override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
     return html`
-      <gr-button id="start-flow" flatten @click=${this.toggleDropdown}
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
         >Topic</gr-button
       >
       <iron-dropdown
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
-        .verticalOffset=${24 /* roughly line height in pixels */}
+        .verticalOffset=${24}
         @opened-changed=${(e: CustomEvent) =>
           (this.isDropdownOpen = e.detail.value)}
       >
@@ -191,6 +195,8 @@
     };
     return html`
       <span
+        role="button"
+        aria-label=${name as string}
         class=${classMap(chipClasses)}
         @click=${() => this.toggleExistingTopicSelected(name)}
       >
@@ -260,18 +266,30 @@
 
   private toggleDropdown() {
     if (this.isDropdownOpen) {
-      this.isDropdownOpen = false;
-      this.dropdown?.close();
+      this.closeDropdown();
     } else {
-      this.topicToAdd = '' as TopicName;
-      this.selectedExistingTopics = new Set();
-      this.overallProgress = ProgressStatus.NOT_STARTED;
-      this.errorText = undefined;
-      this.isDropdownOpen = true;
-      this.dropdown?.open();
+      this.reset();
+      this.openDropdown();
     }
   }
 
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
   private async getTopicSuggestions(
     query: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -288,10 +306,9 @@
   }
 
   private removeTopics() {
-    this.loadingText = `Removing ${pluralize(
-      this.selectedExistingTopics.size,
-      'topic'
-    )}...`;
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
     this.trackPromises(
       this.selectedChanges
         .filter(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 677aeb4..fd78479 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -210,8 +210,12 @@
           >
             <div slot="dropdown-content">
               <div class="chips">
-                <span class="chip">topic1</span>
-                <span class="chip">topic2</span>
+                <span role="button" aria-label="topic1" class="chip"
+                  >topic1</span
+                >
+                <span role="button" aria-label="topic2" class="chip"
+                  >topic2</span
+                >
               </div>
               <div class="footer">
                 <div class="loadingOrError"></div>
@@ -254,7 +258,7 @@
 
       assert.equal(
         queryAndAssert(element, '.loadingText').textContent,
-        'Removing 1 topic...'
+        'Removing topic...'
       );
 
       await resolvePromises();
@@ -277,7 +281,7 @@
 
       assert.equal(
         queryAndAssert(element, '.loadingText').textContent,
-        'Removing 2 topics...'
+        'Removing topics...'
       );
 
       await resolvePromises();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 9af14f5..95ad376 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -173,6 +173,8 @@
 
   @state() private queryTopic?: AutocompleteQuery;
 
+  @state() private queryHashtag?: AutocompleteQuery;
+
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -182,6 +184,7 @@
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
   static override styles = [
@@ -689,6 +692,8 @@
               .readOnly=${this.hashtagReadOnly}
               @changed=${this.handleHashtagChanged}
               showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
             ></gr-editable-label>
           `
         )}
@@ -1192,6 +1197,22 @@
       );
   }
 
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(notUndefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
+  }
+
   private showNewSubmitRequirements() {
     return showNewSubmitRequirements(this.flagsService, this.change);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index 8161592..0005c90 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -125,7 +125,6 @@
         </span>
         <gr-limited-text
           class="name"
-          limit="25"
           tooltip="[[item.tooltip]]"
           text="[[item.fallback_text]]"
         ></gr-limited-text>
@@ -143,7 +142,6 @@
         </span>
         <gr-limited-text
           class="name"
-          limit="25"
           text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
@@ -193,7 +191,6 @@
         </span>
         <gr-limited-text
           class="name"
-          limit="25"
           text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f5893ac..6ec815c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -430,54 +430,58 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().aPluginHasRegistered$,
+      () => this.getChecksModel().aPluginHasRegistered$,
       x => (this.showChecksSummary = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingFirstTime$,
+      () => this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsLatest$,
+      () => this.getChecksModel().topLevelActionsLatest$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelMessagesLatest$,
+      () => this.getChecksModel().topLevelMessagesLatest$,
       x => (this.messages = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threads$,
       x => (this.commentThreads = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.selfAccount = x)
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index e6d89a1..7e4cf2c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1284,6 +1284,10 @@
     if (value.basePatchNum === undefined)
       value.basePatchNum = ParentPatchSetNum;
 
+    if (value.patchNum === undefined) {
+      value.patchNum = computeLatestPatchNum(this._allPatchSets);
+    }
+
     const patchChanged = this.hasPatchRangeChanged(value);
     let patchNumChanged = this.hasPatchNumChanged(value);
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index de9395f..b8d4761 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -93,12 +93,16 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 65e42a1..0902fe6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -663,7 +663,8 @@
       return;
     }
     e.stopPropagation();
-    this.message = {...this.message, expanded: true};
+    this.message.expanded = true;
+    this.requestUpdate();
   }
 
   private handleAuthorClick(e: Event) {
@@ -671,7 +672,8 @@
       return;
     }
     e.stopPropagation();
-    this.message = {...this.message, expanded: false};
+    this.message.expanded = false;
+    this.requestUpdate();
   }
 
   // private but used in tests.
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 9f33990..f6b0ad4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -306,7 +306,7 @@
     super.disconnectedCallback();
   }
 
-  scrollToMessage(messageID: string) {
+  async scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
@@ -324,7 +324,9 @@
       return;
     }
 
-    el.message = {...el.message, expanded: true};
+    el.message.expanded = true;
+    el.requestUpdate();
+    await el.updateComplete;
     let top = el.offsetTop;
     for (
       let offsetParent = el.offsetParent as HTMLElement | null;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 30dd257..b9cb616 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -222,7 +222,7 @@
       assert.isNotOk(query(element, '.showAllActivityToggle'));
     });
 
-    test('scroll to message', () => {
+    test('scroll to message', async () => {
       const allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assertIsDefined(message.message);
@@ -232,7 +232,7 @@
       const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, '_highlightEl');
 
-      element.scrollToMessage('invalid');
+      await element.scrollToMessage('invalid');
 
       for (const message of allMessageEls) {
         assertIsDefined(message.message);
@@ -243,7 +243,7 @@
       }
 
       const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
+      await element.scrollToMessage(messageID);
       assert.isTrue(
         queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
           .message?.expanded
@@ -253,16 +253,16 @@
       assert.isTrue(highlightStub.calledOnce);
     });
 
-    test('scroll to message offscreen', () => {
+    test('scroll to message offscreen', async () => {
       const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, '_highlightEl');
       element.messages = generateRandomMessages(25);
-      flush();
+      await element.updateComplete;
       assert.isFalse(scrollToStub.called);
       assert.isFalse(highlightStub.called);
 
       const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
+      await element.scrollToMessage(messageID);
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
       assert.isTrue(
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 95572a8..1f57837 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,10 +27,7 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -118,6 +115,7 @@
 import {classMap} from 'lit/directives/class-map';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {customElement, property, state, query} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -347,6 +345,8 @@
 
   storeTask?: DelayedTask;
 
+  private isLoggedIn = false;
+
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
@@ -593,6 +593,60 @@
     `,
   ];
 
+  constructor() {
+    super();
+    this.filterReviewerSuggestion =
+      this.filterReviewerSuggestionGenerator(false);
+    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
+    this.restApiService.getAccount().then(account => {
+      if (account) this.account = account;
+    });
+
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this.submit()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this.submit()
+      )
+    );
+    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
+    this.addEventListener('comment-editing-changed', e => {
+      this.commentEditing = (e as CustomEvent).detail;
+    });
+
+    // Plugins on reply-reviewers endpoint can take advantage of these
+    // events to add / remove reviewers
+
+    this.addEventListener('add-reviewer', e => {
+      // Only support account type, see more from:
+      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+      this.reviewersList?.addAccountItem({
+        account: (e as CustomEvent).detail.reviewer,
+        count: 1,
+      });
+    });
+
+    this.addEventListener('remove-reviewer', e => {
+      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
+    });
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('draft')) {
       this.draftChanged(changedProperties.get('draft') as string);
@@ -640,55 +694,6 @@
     }
   }
 
-  constructor() {
-    super();
-    this.filterReviewerSuggestion =
-      this.filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
-    this.restApiService.getAccount().then(account => {
-      if (account) this.account = account;
-    });
-
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this.submit()
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this.submit()
-      )
-    );
-    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
-    this.addEventListener('comment-editing-changed', e => {
-      this.commentEditing = (e as CustomEvent).detail;
-    });
-
-    // Plugins on reply-reviewers endpoint can take advantage of these
-    // events to add / remove reviewers
-
-    this.addEventListener('add-reviewer', e => {
-      // Only support account type, see more from:
-      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
-      this.reviewersList?.addAccountItem({
-        account: (e as CustomEvent).detail.reviewer,
-        count: 1,
-      });
-    });
-
-    this.addEventListener('remove-reviewer', e => {
-      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
-    });
-  }
-
   override disconnectedCallback() {
     this.storeTask?.cancel();
     for (const cleanup of this.cleanups) cleanup();
@@ -1482,7 +1487,7 @@
     const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parseable
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -2079,23 +2084,25 @@
 
   getReviewerSuggestionsProvider(change?: ChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+      ReviewerState.REVIEWER,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
   getCcSuggestionsProvider(change?: ChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+      ReviewerState.CC,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 3616f30..08e3ab4 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -144,11 +144,11 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
   }
@@ -200,7 +200,6 @@
         <td class="name">
           <gr-limited-text
             class="name"
-            limit="25"
             .text=${requirement.name}
           ></gr-limited-text>
         </td>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 6918a1a..09e601c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -99,7 +99,7 @@
               </iron-icon>
             </td>
             <td class="name">
-              <gr-limited-text class="name" limit="25"></gr-limited-text>
+              <gr-limited-text class="name"></gr-limited-text>
             </td>
             <td>
               <gr-endpoint-decorator
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index bf85d11..0ba2e89 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -205,15 +205,23 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
-    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
   }
 
   override willUpdate(changed: PropertyValues) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 9ea29b0..b9beb1d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -114,9 +114,13 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().labels$,
+      x => (this.labels = x)
+    );
   }
 
   static override get styles() {
@@ -608,11 +612,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getConfigModel().repoConfig$,
+      () => this.getConfigModel().repoConfig$,
       x => (this.repoConfig = x)
     );
   }
@@ -787,31 +791,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsSelected$,
+      () => this.getChecksModel().topLevelActionsSelected$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelLinksSelected$,
+      () => this.getChecksModel().topLevelLinksSelected$,
       x => (this.links = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingSelected$,
+      () => this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index b18a5ff..414be21 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -429,21 +429,21 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d808d11..bb34dec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -73,31 +73,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().allResultsSelected$,
+      () => this.getChecksModel().allResultsSelected$,
       x => (this.results = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 39fc048..896a9b2 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -131,11 +131,11 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index dc8e7a6..48d50c7 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -122,13 +122,17 @@
     this.addEventListener('content-change', e => {
       this.handleContentChange(e as CustomEvent<{value: string}>);
     });
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.editPrefs = editPreferences;
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.editPrefs = editPreferences;
-    });
     this.cleanups.push(
       addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
         this.handleSaveShortcut()
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index b37a978..fdd24cf 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -5,50 +5,46 @@
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
 import {Observable, Subscription} from 'rxjs';
+import {Provider} from '../../models/dependency';
 
-const SUBSCRIPTION_SYMBOL = Symbol('subscriptions');
-
-// Checks whether a subscription can be added. Returns true if it can be added,
-// return false if it's already present.
-// Subscriptions are stored on the host so they have the same life-time as the
-// host.
-function checkSubscription<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
-): boolean {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const hostSubscriptions = ((host as any)[SUBSCRIPTION_SYMBOL] ||= new Map());
-  if (!hostSubscriptions.has(obs$)) hostSubscriptions.set(obs$, new Set());
-  const obsSubscriptions = hostSubscriptions.get(obs$);
-  if (obsSubscriptions.has(setProp)) return false;
-  obsSubscriptions.add(setProp);
-  return true;
+export class SubscriptionError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
 }
 
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
- * subscribe(this, obs$, x => (this.prop = x));
+ * subscribe(this, () => obs$, x => (this.prop = x));
  */
 export function subscribe<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
+  host: ReactiveControllerHost & HTMLElement,
+  provider: Provider<Observable<T>>,
+  callback: (t: T) => void
 ) {
-  if (!checkSubscription(host, obs$, setProp)) return;
-  host.addController(new SubscriptionController(obs$, setProp));
+  if (host.isConnected)
+    throw new Error(
+      'Subscriptions should happen before a component is connected'
+    );
+  const controller = new SubscriptionController(provider, callback);
+  host.addController(controller);
 }
+
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
   constructor(
-    private readonly obs$: Observable<T>,
-    private readonly setProp: (t: T) => void
+    private readonly provider: Provider<Observable<T>>,
+    private readonly callback: (t: T) => void
   ) {}
 
   hostConnected() {
-    this.sub = this.obs$.subscribe(this.setProp);
+    this.sub = this.provider().subscribe(v => this.update(v));
+  }
+
+  update(value: T) {
+    this.callback(value);
   }
 
   hostDisconnected() {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 0f7e065..94ccc16 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -60,12 +60,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.originalEditPrefs = editPreferences;
-      this.editPrefs = {...editPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.originalEditPrefs = editPreferences;
+        this.editPrefs = {...editPreferences};
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 845b30c..46c2956 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -35,12 +35,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      this.originalPrefs = prefs;
-      this.menuItems = [...prefs.my];
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.menuItems = [...prefs.my];
+      }
+    );
   }
 
   static override styles = [
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 471ebd6..f5f9584 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -264,35 +264,49 @@
     super();
     this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
     this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
-  }
-
-  override connectedCallback(): void {
-    super.connectedCallback();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
-    subscribe(this, this.userModel.diffPreferences$, x =>
-      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
     );
-    subscribe(this, this.userModel.preferences$, prefs => {
-      const layers: DiffLayer[] = [this.syntaxLayer];
-      if (!prefs.disable_token_highlighting) {
-        layers.push(new TokenHighlightLayer(this));
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!prefs.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
       }
-      this.layers = layers;
-    });
-    subscribe(this, this.userModel.diffPreferences$, prefs => {
-      this.prefs = {
-        ...prefs,
-        // set line_wrapping to true so that the context can take all the
-        // remaining space after comment card has rendered
-        line_wrapping: true,
-      };
-    });
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      prefs => {
+        this.prefs = {
+          ...prefs,
+          // set line_wrapping to true so that the context can take all the
+          // remaining space after comment card has rendered
+          line_wrapping: true,
+        };
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index c460ad7..3e4a555 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -248,27 +248,36 @@
         });
       }
     }
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.configModel().repoCommentLinks$,
+      () => this.configModel().repoCommentLinks$,
       x => (this.commentLinks = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
-
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.isAdmin$,
+      x => (this.isAdmin = x)
+    );
+
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
     subscribe(
       this,
-      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () =>
+        this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
       () => {
         this.autoSave();
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 5fd7db4..cb51337 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -65,13 +65,17 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.diffPreferences$, diffPreferences => {
-      if (!diffPreferences) return;
-      this.originalDiffPrefs = diffPreferences;
-      this.diffPrefs = {...diffPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.originalDiffPrefs = diffPreferences;
+        this.diffPrefs = {...diffPreferences};
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 99b72fa..bdd2b52 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -424,8 +424,9 @@
     }
   }
 
-  handleEditCommitMessage() {
+  async handleEditCommitMessage() {
     this.editing = true;
+    await this.updateComplete;
     this.focusTextarea();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 4493e8d..ce5ef4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -292,7 +292,7 @@
             class="attentionIcon"
             icon="gr-icons:attention"
           ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
+          <span> ${this.computePronoun()} turn to take action. </span>
           <a
             href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
             target="_blank"
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 4456381..dc2cbc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -25,7 +25,7 @@
       <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=unfold_more -->
       <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
@@ -61,11 +61,11 @@
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mode_comment-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=calendar_today-->
       <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
@@ -77,11 +77,13 @@
       <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="build"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle-->
       <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle_outline-->
       <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=check+circle-->
       <g id="check-circle-filled"><path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M10,17l-4-4l1.4-1.4l2.6,2.6l6.6-6.6 L18,9L10,17z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
@@ -110,45 +112,45 @@
       <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
+      <!-- This SVG is an adaptation of material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=label_important-->
       <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=pets-->
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=visibility-->
       <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
       <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#warning-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=warning-->
       <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#timelapse-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=timelapse-->
       <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mark_chat_read-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mark_chat_read-->
       <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#message-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=message-->
       <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=launch-->
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=filter-->
       <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_down-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_down-->
       <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_up-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_up-->
       <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#insert_photo-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=insert_photo-->
       <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#download-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=download-->
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#swap_horiz-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
       <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=link-->
       <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 9bb112e..6b5fc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {customElement, property} from 'lit/decorators';
-import {html, LitElement} from 'lit';
+import {css, html, LitElement} from 'lit';
 import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
@@ -38,13 +38,19 @@
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit = 0;
+  limit = 25;
 
   @property({type: String})
   tooltip?: string;
 
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          white-space: nowrap;
+        }
+      `,
+    ];
   }
 
   override render() {
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 77a5dfb..a5effaf 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -200,8 +200,8 @@
     }
   `;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     this.setupButtonHoverHandler();
   }
 
@@ -220,16 +220,17 @@
   private setupButtonHoverHandler() {
     subscribe(
       this,
-      this.expandButtonsHover.pipe(
-        switchMap(e => {
-          if (e.eventType === 'leave') {
-            // cancel any previous delay
-            // for mouse enter
-            return EMPTY;
-          }
-          return of(e).pipe(delay(500));
-        })
-      ),
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
       ({buttonType, linesToExpand}) => {
         fire(this, 'diff-context-button-hovered', {
           buttonType,
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index e7ac242c..9f7398d 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -102,7 +102,7 @@
  * Type Safety
  * ---
  *
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
  * typing of the token used to tie together dependency providers and dependency
  * consumers.
  *
@@ -133,16 +133,38 @@
  */
 export type Provider<T> = () => T;
 
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+  [PROVIDERS_SYMBOL]?: Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >;
+  [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
 /**
  * A producer of a dependency expresses this as a need that results in a promise
  * for the given dependency.
  */
 export function provide<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>,
   provider: Provider<T>
 ) {
-  host.addController(new DependencyProvider<T>(host, dependency, provider));
+  const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >());
+  const oldController = hostProviders.get(dependency);
+  if (oldController) {
+    host.removeController(oldController);
+    oldController.hostDisconnected();
+  }
+  const controller = new DependencyProvider<T>(host, dependency, provider);
+  hostProviders.set(dependency, controller);
+  host.addController(controller);
 }
 
 /**
@@ -151,12 +173,21 @@
  * the injected value.
  */
 export function resolve<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>
 ): Provider<T> {
-  const controller = new DependencySubscriber(host, dependency);
-  host.addController(controller);
-  return () => controller.get();
+  const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    Provider<unknown>
+  >());
+  let resolver = hostResolvers.get(dependency);
+  if (!resolver) {
+    const controller = new DependencySubscriber(host, dependency);
+    host.addController(controller);
+    resolver = () => controller.get();
+    hostResolvers.set(dependency, resolver);
+  }
+  return resolver as Provider<T>;
 }
 
 /**
@@ -249,7 +280,7 @@
 }
 
 /**
- * A resolved dependency is valid within the econnectd lifetime of a component,
+ * A resolved dependency is valid within the connected lifetime of a component,
  * namely between connectedCallback and disconnectedCallback.
  */
 interface ResolvedDependency<T> {
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
index 83feac7..36d73e5 100644
--- a/polygerrit-ui/app/models/di-provider-element_test.ts
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -26,9 +26,13 @@
   @state()
   private injectedValue = '';
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getModel(), value => (this.injectedValue = value));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getModel(),
+      value => (this.injectedValue = value)
+    );
   }
 
   override render() {
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index d0f79f4..5cb57aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -28,25 +28,14 @@
   SuggestedReviewerInfo,
   Suggestion,
 } from '../../types/common';
-import {assertNever} from '../../utils/common-util';
+import {assertNever, intersection} from '../../utils/common-util';
 import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
-
-// TODO(TS): enum name doesn't follow typescript style guid rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-  ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {notUndefined} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {ReviewerState} from '../../api/rest-api';
 
 export interface ReviewerSuggestionsProvider {
-  init(): void;
   getSuggestions(input: string): Promise<Suggestion[]>;
   makeSuggestionItem(
     suggestion: Suggestion
@@ -56,66 +45,33 @@
 export class GrReviewerSuggestionsProvider
   implements ReviewerSuggestionsProvider
 {
-  static create(
-    restApi: RestApiService,
-    changeNumber: NumericChangeId,
-    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  private changeNumbers: NumericChangeId[];
+
+  constructor(
+    private restApi: RestApiService,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
+    private config: ServerInfo | undefined,
+    private loggedIn: boolean,
+    ...changeNumbers: NumericChangeId[]
   ) {
-    switch (userType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedReviewers(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedCCs(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
-        );
-      default:
-        throw new Error(`Unknown users type: ${userType}`);
-    }
+    this.changeNumbers = changeNumbers;
   }
 
-  private initPromise?: Promise<void>;
+  async getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this.loggedIn) return [];
 
-  config?: ServerInfo;
-
-  loggedIn = false;
-
-  private initialized = false;
-
-  private constructor(
-    private readonly _restAPI: RestApiService,
-    private readonly _apiCall: ApiCallCallback
-  ) {}
-
-  init() {
-    if (this.initPromise) {
-      return this.initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
-      () => {
-        this.initialized = true;
-      }
+    const allResults = await allSettled(
+      this.changeNumbers.map(changeNumber =>
+        this.getSuggestionsForChange(changeNumber, input)
+      )
     );
-    return this.initPromise;
-  }
-
-  getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this.initialized || !this.loggedIn) {
-      return Promise.resolve([]);
-    }
-
-    return this._apiCall(input).then(reviewers => reviewers || []);
+    const allSuggestions = allResults
+      .filter(isFulfilled)
+      .map(result => result.value)
+      .filter(notUndefined);
+    return intersection(allSuggestions, (s1, s2) =>
+      this.areSameSuggestions(s1, s2)
+    );
   }
 
   makeSuggestionItem(
@@ -137,7 +93,7 @@
       };
     }
 
-    if (isAccountSuggestions(suggestion)) {
+    if (this.isAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
         name: getAccountDisplayName(this.config, suggestion),
@@ -146,4 +102,28 @@
     }
     assertNever(suggestion, 'Received an incorrect suggestion');
   }
+
+  private getSuggestionsForChange(
+    changeNumber: NumericChangeId,
+    input: string
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
+      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
+      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+  }
+
+  private areSameSuggestions(a: Suggestion, b: Suggestion): boolean {
+    if (isReviewerAccountSuggestion(a) && isReviewerAccountSuggestion(b)) {
+      return accountKey(a.account) === accountKey(b.account);
+    } else if (isReviewerGroupSuggestion(a) && isReviewerGroupSuggestion(b)) {
+      return a.group.id === b.group.id;
+    } else if (this.isAccountSuggestion(a) && this.isAccountSuggestion(b)) {
+      return accountKey(a) === accountKey(b);
+    }
+    return false;
+  }
+
+  private isAccountSuggestion(s: Suggestion): s is AccountInfo {
+    return (s as AccountInfo)._account_id !== undefined;
+  }
 }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index 757bcca..3dc30dd 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -16,244 +16,197 @@
  */
 
 import '../../test/common-test-setup-karma';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from './gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../services/app-context';
 import {stubRestApi} from '../../test/test-utils';
 import {
-  AccountId,
-  AccountInfo,
   ChangeInfo,
-  EmailAddress,
   GroupId,
   GroupName,
   NumericChangeId,
+  ReviewerState,
 } from '../../api/rest-api';
-import {SuggestedReviewerInfo} from '../../types/common';
-import {createChange, createServerInfo} from '../../test/test-data-generators';
+import {
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../types/common';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
 
 suite('GrReviewerSuggestionsProvider tests', () => {
-  let _nextAccountId = 0;
-  function makeAccount(opt_status?: string): AccountInfo {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId as AccountId,
-      name: `name ${accountId}`,
-      email: `email ${accountId}` as EmailAddress,
-      status: opt_status,
-    };
-  }
-  let _nextAccountId2 = 0;
-  function makeAccount2(opt_status?: string): AccountInfo {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2 as AccountId,
-      name: `name ${accountId2}`,
-      status: opt_status,
-    };
-  }
-
-  let owner: AccountInfo;
-  let existingReviewer1: AccountInfo;
-  let existingReviewer2: AccountInfo;
-  let suggestion1: SuggestedReviewerInfo;
-  let suggestion2: SuggestedReviewerInfo;
-  let suggestion3: SuggestedReviewerInfo;
+  const suggestion1: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(3),
+    count: 1,
+  };
+  const suggestion2: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(4),
+    count: 1,
+  };
+  const suggestion3: SuggestedReviewerGroupInfo = {
+    group: {
+      id: 'suggested group id' as GroupId,
+      name: 'suggested group' as GroupName,
+    },
+    count: 4,
+  };
+  const change: ChangeInfo = createChange();
+  let getChangeSuggestedReviewersStub: sinon.SinonStub;
+  let getChangeSuggestedCCsStub: sinon.SinonStub;
   let provider: GrReviewerSuggestionsProvider;
 
-  let redundantSuggestion1: SuggestedReviewerInfo;
-  let redundantSuggestion2: SuggestedReviewerInfo;
-  let redundantSuggestion3: SuggestedReviewerInfo;
-  let change: ChangeInfo;
-
-  setup(async () => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount(), count: 1};
-    suggestion2 = {account: makeAccount(), count: 1};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id' as GroupId,
-        name: 'suggested group' as GroupName,
-      },
-      count: 1,
-    };
-
-    stubRestApi('getConfig').resolves(createServerInfo());
-
-    change = {
-      ...createChange(),
-      _number: 42 as NumericChangeId,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-
-    await flush();
+  setup(() => {
+    getChangeSuggestedReviewersStub = stubRestApi(
+      'getChangeSuggestedReviewers'
+    ).resolves([suggestion1, suggestion2, suggestion3]);
+    getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs').resolves([
+      suggestion1,
+      suggestion2,
+      suggestion3,
+    ]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      change._number
+    );
   });
 
-  suite('allowAnyUser set to false', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-        getAppContext().restApiService,
-        change._number,
-        SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
-      );
-      await provider.init();
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      let getChangeSuggestedReviewersStub: sinon.SinonStub;
-      setup(() => {
-        getChangeSuggestedReviewersStub = stubRestApi(
-          'getChangeSuggestedReviewers'
-        ).callsFake(() => {
-          redundantSuggestion1 = {account: existingReviewer1, count: 1};
-          redundantSuggestion2 = {account: existingReviewer2, count: 1};
-          redundantSuggestion3 = {account: owner, count: 1};
-          return Promise.resolve([
-            redundantSuggestion1,
-            redundantSuggestion2,
-            redundantSuggestion3,
-            suggestion1,
-            suggestion2,
-            suggestion3,
-          ]);
-        });
-      });
+  test('getSuggestions', async () => {
+    const reviewers = await provider.getSuggestions('');
 
-      test('makeSuggestionItem formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account, count: 1});
-        assert.deepEqual(suggestion, {
-          name: `${account.name} <${account.email}>`,
-          value: {account, count: 1},
-        });
-
-        const group = {name: 'test' as GroupName, id: '5' as GroupId};
-        suggestion = provider.makeSuggestionItem({group, count: 1});
-        assert.deepEqual(suggestion, {
-          name: `${group.name} (group)`,
-          value: {group, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: `${account.name} <${account.email}>`,
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}, count: 1});
-        assert.deepEqual(suggestion, {
-          name: 'Name of user not set',
-          value: {account: {}, count: 1},
-        });
-
-        provider.config = {
-          ...createServerInfo(),
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}, count: 1});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}, count: 1},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account, count: 1});
-        assert.deepEqual(suggestion, {
-          name: `${account.name} <${account.email}> (OOO)`,
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: `${account.name} <${account.email}> (OOO)`,
-          value: {account, count: 1},
-        });
-
-        account3.email = undefined;
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', async () => {
-        const reviewers = await provider.getSuggestions('');
-
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
-        assert.deepEqual(reviewers, [
-          redundantSuggestion1,
-          redundantSuggestion2,
-          redundantSuggestion3,
-          suggestion1,
-          suggestion2,
-          suggestion3,
-        ]);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        provider.loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider.loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(getChangeSuggestedReviewersStub.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', async () => {
-      const suggestReviewerStub = stubRestApi(
-        'getChangeSuggestedReviewers'
-      ).returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts').returns(
-        Promise.resolve([])
-      );
-
-      await provider.getSuggestions('');
-      assert.isTrue(suggestReviewerStub.calledOnce);
-      assert.isTrue(suggestReviewerStub.calledWith(42 as NumericChangeId, ''));
-      assert.isFalse(suggestAccountStub.called);
-    });
+    assert.sameDeepMembers(reviewers, [suggestion1, suggestion2, suggestion3]);
   });
 
-  suite('allowAnyUser set to true', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-        getAppContext().restApiService,
-        change._number,
-        SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
-      );
-      await provider.init();
+  test('getSuggestions short circuits when logged out', async () => {
+    await provider.getSuggestions('');
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+
+    // not logged in
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      false,
+      change._number
+    );
+
+    await provider.getSuggestions('');
+
+    // no additional call is made
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+  });
+
+  test('only returns REVIEWER suggestions shared by all changes', async () => {
+    getChangeSuggestedReviewersStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('only returns CC suggestions shared by all changes', async () => {
+    getChangeSuggestedCCsStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.CC,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('makeSuggestionItem formats account or group accordingly', () => {
+    let account = createAccountDetailWithIdNameAndEmail(1);
+    const account3 = createAccountDetailWithIdNameAndEmail(2);
+    let suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
     });
 
-    test('getSuggestedAccounts is used', async () => {
-      const suggestReviewerStub = stubRestApi(
-        'getChangeSuggestedReviewers'
-      ).returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts').returns(
-        Promise.resolve([])
-      );
+    const group = {name: 'test' as GroupName, id: '5' as GroupId};
+    suggestion = provider.makeSuggestionItem({group, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${group.name} (group)`,
+      value: {group, count: 1},
+    });
 
-      await provider.getSuggestions('');
-      assert.isFalse(suggestReviewerStub.called);
-      assert.isTrue(suggestAccountStub.calledOnce);
-      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Name of user not set',
+      value: {account: {}, count: 1},
+    });
+
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward Name',
+        },
+      },
+      true,
+      change._number
+    );
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Anonymous Coward Name',
+      value: {account: {}, count: 1},
+    });
+
+    account = {...createAccountDetailWithIdNameAndEmail(3), status: 'OOO'};
+
+    suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    account3.email = undefined;
+
+    suggestion = provider.makeSuggestionItem(account3);
+    assert.deepEqual(suggestion, {
+      name: account3.name,
+      value: {account: account3, count: 1},
     });
   });
 });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 8e6a147..2b4fc60 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1817,7 +1817,7 @@
   }
 
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = [`intopic:"${topic}"`].join(' ');
+    const query = `intopic:"${topic}"`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
@@ -1825,6 +1825,17 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:"${hashtag}"`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index e727216..0ea561f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -634,6 +634,9 @@
     }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index d91b438..ae8545a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -280,6 +280,9 @@
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 43fd6f5..981bcae 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -147,12 +147,18 @@
 
 export const isFalse = (b: boolean) => b === false;
 
-// An equivalent to Promise.allSettled from ES2020.
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
-// TODO: Migrate our tooling to ES2020 and remove this method.
 export type PromiseResult<T> =
   | {status: 'fulfilled'; value: T}
   | {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+  promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+  return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
 export function allSettled<T>(
   promises: Promise<T>[]
 ): Promise<PromiseResult<T>[]> {
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..6ccf770 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -156,3 +156,22 @@
 export function unique<T>(item: T, index: number, array: T[]) {
   return array.indexOf(item) === index;
 }
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality.
+ */
+export function intersection<T>(
+  arrays: T[][],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  // Array.prototype.reduce needs either an initialValue or a non-empty array.
+  // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+  // empty array must be checked separately.
+  if (arrays.length === 0) {
+    return [];
+  }
+  return arrays.reduce((result, array) =>
+    result.filter(t => array.find(u => compareBy(t, u)))
+  );
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..8cc523a 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -16,7 +16,12 @@
  */
 
 import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {
+  hasOwnProperty,
+  areSetsEqual,
+  containsAll,
+  intersection,
+} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -68,4 +73,28 @@
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
   });
+
+  test('intersections', () => {
+    assert.sameDeepMembers(intersection([]), []);
+    assert.sameDeepMembers(intersection([[1, 2, 3]]), [1, 2, 3]);
+    assert.sameDeepMembers(
+      intersection([
+        [1, 2, 3],
+        [2, 3, 4],
+        [5, 3, 2],
+      ]),
+      [2, 3]
+    );
+
+    const foo1 = {value: 5};
+    const foo2 = {value: 5};
+
+    // these foo's will fail strict equality with each other, but a comparator
+    // can make them intersect.
+    assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+    assert.sameDeepMembers(
+      intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+      [foo1]
+    );
+  });
 });