Merge changes I022c6a13,I799bf1c5

* changes:
  Skip account visibility checks when querying changes
  Check permissions when resolving accounts by secondary emails
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 70352dc..e6494e6 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -199,6 +199,14 @@
 
 === Change
 
+* `change/count_rebases`: Total number of rebases
+** `on_behalf_of_uploader`:
+   Whether the rebase was done on behalf of the uploader.
+** `rebase_chain`:
+   Whether a chain was rebased.
+* `change/submitted_with_rebaser_approval`: Number of rebased changes that were
+  submitted with a Code-Review approval of the rebaser that would not have been
+  submittable if the rebase was not done on behalf of the uploader.
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a
   change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 2810d1e..aceb38e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3799,6 +3799,10 @@
 If another user removed a user's vote, the user with the deleted vote will be
 added to the attention set.
 
+The request returns:
+ * '204 No Content' if the vote is deleted successfully;
+ * '404 Not Found' when the vote to be deleted is zero or not present.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 85c5b6d..647eb9d 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -14,20 +14,26 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.Field;
 import com.google.inject.Singleton;
+import java.util.Arrays;
 import java.util.concurrent.ConcurrentHashMap;
 import org.apache.commons.lang3.mutable.MutableLong;
 
 /**
  * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
  *
- * <p>Records how often {@link Counter0} metrics are invoked. Metrics of other types are not
- * recorded.
+ * <p>Records how often counter metrics are invoked. Metrics of other types are not recorded.
  *
- * <p>Allows test to check how much a {@link Counter0} metrics is increased by an operation.
+ * <p>Allows test to check how much a counter metrics is increased by an operation.
  *
  * <p>Example:
  *
@@ -48,18 +54,18 @@
  */
 @Singleton
 public class TestMetricMaker extends DisabledMetricMaker {
-  private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
+  private final ConcurrentHashMap<CounterKey, MutableLong> counts = new ConcurrentHashMap<>();
 
-  public long getCount(String counter0Name) {
-    return get(counter0Name).longValue();
+  public long getCount(String counterName, Object... fieldValues) {
+    return get(CounterKey.create(counterName, fieldValues)).longValue();
   }
 
   public void reset() {
     counts.clear();
   }
 
-  private MutableLong get(String counter0Name) {
-    return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
+  private MutableLong get(CounterKey counterKey) {
+    return counts.computeIfAbsent(counterKey, key -> new MutableLong(0));
   }
 
   @Override
@@ -67,11 +73,64 @@
     return new Counter0() {
       @Override
       public void incrementBy(long value) {
-        get(name).add(value);
+        get(CounterKey.create(name)).add(value);
       }
 
       @Override
       public void remove() {}
     };
   }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
+    return new Counter1<>() {
+      @Override
+      public void incrementBy(F1 field1, long value) {
+        get(CounterKey.create(name, field1)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
+    return new Counter2<>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, long value) {
+        get(CounterKey.create(name, field1, field2)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Counter3<>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
+        get(CounterKey.create(name, field1, field2, field3)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @AutoValue
+  abstract static class CounterKey {
+    abstract String name();
+
+    abstract ImmutableList<Object> fieldValues();
+
+    static CounterKey create(String name, Object... fieldValues) {
+      return new AutoValue_TestMetricMaker_CounterKey(
+          name, ImmutableList.copyOf(Arrays.asList(fieldValues)));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7bbee2a..ff811a0 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -242,7 +242,9 @@
   }
 
   private int getInsertionsCount() {
-    return listModifiedFiles().values().stream()
+    return listModifiedFiles().entrySet().stream()
+        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
+        .map(Map.Entry::getValue)
         .map(FileDiffOutput::insertions)
         .reduce(0, Integer::sum);
   }
@@ -323,8 +325,8 @@
                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
-                modifiedFiles.size() - 1, //
-                getInsertionsCount(), //
+                modifiedFiles.size() - 1, // -1 to account for the commit message
+                getInsertionsCount(),
                 getDeletionsCount()));
         detail.append("\n");
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 9c340c4..e9bf3c2 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -118,8 +118,12 @@
    * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> idStr(Change.Id id) {
+    return idStr(id.toString());
+  }
+
+  public static Predicate<ChangeData> idStr(String id) {
     return new ChangeIndexCardinalPredicate(
-        ChangeField.NUMERIC_ID_STR_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+        ChangeField.NUMERIC_ID_STR_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id, 1);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index 32a8fdf..9120069 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -99,6 +99,15 @@
     return new EqualsLabelPredicate(args, label, value, account, count);
   }
 
+  public String getLabel() {
+    return magicLabelVote.label();
+  }
+
+  public boolean ignoresUploaderApprovals() {
+    return account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+        || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID);
+  }
+
   @Nullable
   protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind).isPresent()) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 0e1a218..3ac4d22 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -153,10 +153,12 @@
       }
       // Set the approval to 0 if vote is being removed.
       newApprovals.put(a.label(), (short) 0);
-      found = true;
-
-      // Set old value, as required by VoteDeleted.
-      oldApprovals.put(a.label(), a.value());
+      // If the value is 0, we treat it as already deleted, so no additional actions is required
+      if (a.value() != 0) {
+        found = true;
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.label(), a.value());
+      }
       break;
     }
     if (!found) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index fd51fbc..5368c75 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -84,6 +84,7 @@
   private final PatchSetUtil patchSetUtil;
   private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeResource.Factory changeResourceFactory;
+  private final RebaseMetrics rebaseMetrics;
 
   @Inject
   public Rebase(
@@ -96,7 +97,8 @@
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil,
       IdentifiedUser.GenericFactory userFactory,
-      ChangeResource.Factory changeResourceFactory) {
+      ChangeResource.Factory changeResourceFactory,
+      RebaseMetrics rebaseMetrics) {
     this.serverIdent = serverIdent;
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
@@ -107,6 +109,7 @@
     this.patchSetUtil = patchSetUtil;
     this.userFactory = userFactory;
     this.changeResourceFactory = changeResourceFactory;
+    this.rebaseMetrics = rebaseMetrics;
   }
 
   @Override
@@ -147,6 +150,8 @@
         bu.addOp(change.getId(), rebaseOp);
         bu.execute();
 
+        rebaseMetrics.countRebase(input.onBehalfOfUploader);
+
         ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
         changeInfo.containsGitConflicts =
             !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index 34a2623..1949c89 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -86,6 +86,7 @@
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
   private final ChangeJson.Factory json;
+  private final RebaseMetrics rebaseMetrics;
 
   @Inject
   RebaseChain(
@@ -99,7 +100,8 @@
       ChangeNotes.Factory notesFactory,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil,
-      ChangeJson.Factory json) {
+      ChangeJson.Factory json,
+      RebaseMetrics rebaseMetrics) {
     this.repoManager = repoManager;
     this.getRelatedChangesUtil = getRelatedChangesUtil;
     this.changeDataFactory = changeDataFactory;
@@ -111,6 +113,7 @@
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
     this.json = json;
+    this.rebaseMetrics = rebaseMetrics;
   }
 
   @Override
@@ -194,6 +197,8 @@
       }
     }
 
+    rebaseMetrics.countRebaseChain(input.onBehalfOfUploader);
+
     RebaseChainInfo res = new RebaseChainInfo();
     res.rebasedChanges = new ArrayList<>();
     ChangeJson changeJson = json.create(OPTIONS);
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
new file mode 100644
index 0000000..114a112
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Metrics for the rebase REST endpoints ({@link Rebase} and {@link RebaseChain}). */
+@Singleton
+public class RebaseMetrics {
+  private final Counter2<Boolean, Boolean> countRebases;
+
+  @Inject
+  public RebaseMetrics(MetricMaker metricMaker) {
+    this.countRebases =
+        metricMaker.newCounter(
+            "change/count_rebases",
+            new Description("Total number of rebases").setRate(),
+            Field.ofBoolean("on_behalf_of_uploader", (metadataBuilder, isOnBehalfOfUploader) -> {})
+                .description("Whether the rebase was done on behalf of the uploader.")
+                .build(),
+            Field.ofBoolean("rebase_chain", (metadataBuilder, isRebaseChain) -> {})
+                .description("Whether a chain was rebased.")
+                .build());
+  }
+
+  public void countRebase(boolean isOnBehalfOfUploader) {
+    countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ false);
+  }
+
+  public void countRebaseChain(boolean isOnBehalfOfUploader) {
+    countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ true);
+  }
+
+  private void countRebase(boolean isOnBehalfOfUploader, boolean isRebaseChain) {
+    countRebases.increment(/* field1= */ isOnBehalfOfUploader, /* field2= */ isRebaseChain);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
new file mode 100644
index 0000000..9eb8061
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.MagicLabelPredicate;
+import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Metrics are recorded when a change is merged (aka submitted). */
+public class MergeMetrics {
+  private final Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder;
+
+  // TODO: This metric is for measuring the impact of allowing users to rebase changes on behalf of
+  // the uploader. Once this feature has been rolled out and its impact as been measured, we may
+  // remove this metric.
+  private final Counter0 countChangesThatWereSubmittedWithRebaserApproval;
+
+  @Inject
+  public MergeMetrics(
+      Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder,
+      MetricMaker metricMaker) {
+    this.submitRequirementChangequeryBuilder = submitRequirementChangequeryBuilder;
+
+    this.countChangesThatWereSubmittedWithRebaserApproval =
+        metricMaker.newCounter(
+            "change/submitted_with_rebaser_approval",
+            new Description(
+                    "Number of rebased changes that were submitted with a Code-Review approval of"
+                        + " the rebaser that would not have been submittable if the rebase was not"
+                        + " done on behalf of the uploader.")
+                .setRate());
+  }
+
+  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
+    if (isRebaseOnBehalfOfUploader(cd)
+        && hasCodeReviewApprovalOfRealUploader(cd)
+        && ignoresCodeReviewApprovalsOfUploader(cd)) {
+      // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
+      // The uploader of the patch set is the original uploader on whom's behalf the rebase was
+      // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
+      // clicking on the rebase button).
+      //
+      // 2. The change has Code-Review approvals of the real uploader (aka the rebaser).
+      //
+      // 3. Code-Review approvals of the uploader are ignored.
+      //
+      // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+      // rebaser would have been the uploader of the patch set. In this case the Code-Review
+      // approval of the rebaser would not have counted since Code-Review approvals of the uploader
+      // are ignored.
+      //
+      // In this case we assume that the change would not be submittable if a normal rebase had been
+      // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
+      // it's good enough for the metric.
+      countChangesThatWereSubmittedWithRebaserApproval.increment();
+    }
+  }
+
+  private boolean isRebaseOnBehalfOfUploader(ChangeData cd) {
+    // If the uploader differs from the real uploader the upload of the patch set has been
+    // impersonated. Impersonating the uploader is only allowed on rebase by rebasing on behalf of
+    // the uploader. Hence if the current patch set has different accounts as uploader and real
+    // uploader we can assume that it was created by rebase on behalf of the uploader.
+    return !cd.currentPatchSet().uploader().equals(cd.currentPatchSet().realUploader());
+  }
+
+  private boolean hasCodeReviewApprovalOfRealUploader(ChangeData cd) {
+    return cd.currentApprovals().stream()
+        .anyMatch(psa -> psa.accountId().equals(cd.currentPatchSet().realUploader()));
+  }
+
+  private boolean ignoresCodeReviewApprovalsOfUploader(ChangeData cd) {
+    for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) {
+      try {
+        Predicate<ChangeData> predicate =
+            submitRequirementChangequeryBuilder
+                .get()
+                .parse(submitRequirement.submittabilityExpression().expressionString());
+        return ignoresCodeReviewApprovalsOfUploader(predicate);
+      } catch (QueryParseException e) {
+        return false;
+      }
+    }
+    return false;
+  }
+
+  private boolean ignoresCodeReviewApprovalsOfUploader(Predicate<ChangeData> predicate) {
+    if (predicate.getChildCount() == 0) {
+      // Submit requirements may require a Code-Review approval but ignore approvals by the
+      // uploader. This is done by using a label predicate with 'user=non_uploader' or
+      // 'user=non_contributor', e.g. 'label:Code-Review=+2,user=non_uploader'. After the submit
+      // requirement expression has been parsed these label predicates are represented by
+      // MagicLabelPredicate in the predicate tree. Hence to know whether Code-Review approvals of
+      // the uploader are ignored, we must check if there is any MagicLabelPredicate for the
+      // Code-Review label that ignores approvals of the uploader (aka has user set to non_uploader
+      // or non_contributor).
+      if (predicate instanceof MagicLabelPredicate) {
+        MagicLabelPredicate magicLabelPredicate = (MagicLabelPredicate) predicate;
+        if (magicLabelPredicate.getLabel().equalsIgnoreCase("Code-Review")
+            && magicLabelPredicate.ignoresUploaderApprovals()) {
+          return true;
+        }
+      }
+      return false;
+    }
+
+    for (Predicate<ChangeData> childPredicate : predicate.getChildren()) {
+      if (ignoresCodeReviewApprovalsOfUploader(childPredicate)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 1d3ec73..d299614 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -246,6 +246,7 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
+  private final MergeMetrics mergeMetrics;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -280,7 +281,8 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
+      MergeMetrics mergeMetrics) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -298,6 +300,7 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
+    this.mergeMetrics = mergeMetrics;
   }
 
   @Override
@@ -376,6 +379,7 @@
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
           checkSubmitRequirements(cd);
+          mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
diff --git a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
new file mode 100644
index 0000000..3464d21
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link TestMetricMaker}. */
+public class TestMetricMakerTest {
+  private TestMetricMaker testMetricMaker = new TestMetricMaker();
+
+  @Before
+  public void setUp() {
+    testMetricMaker.reset();
+  }
+
+  @Test
+  public void counter0() throws Exception {
+    String counterName = "test_counter";
+    Counter0 counter = testMetricMaker.newCounter(counterName, new Description("Test Counter"));
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+
+    counter.increment();
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(1);
+
+    counter.incrementBy(/* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(4);
+  }
+
+  @Test
+  public void counter1_booleanField() throws Exception {
+    String counterName = "test_counter";
+    Counter1<Boolean> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.increment(/* field1= */ true);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.increment(/* field1= */ false);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(5);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter1_stringField() throws Exception {
+    String counterName = "test_counter";
+    Counter1<String> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.increment(/* field1= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ "foo", /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.increment(/* field1= */ "bar");
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ "bar", /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(5);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter2() throws Exception {
+    String counterName = "test_counter";
+    Counter2<Boolean, String> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.increment(/* field1= */ true, /* field2= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(5);
+
+    counter.increment(/* field1= */ true, /* field2= */ "bar");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* value= */ 5);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(6);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter3() throws Exception {
+    String counterName = "test_counter";
+    Counter3<Boolean, String, Integer> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build(),
+            Field.ofInteger("integer_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.increment(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0, /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0, /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(5);
+
+    counter.increment(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0, /* value= */ 5);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(6);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1, /* value= */ 6);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(7);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e3d69e1..21fc4b4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -101,6 +101,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -196,6 +197,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -4551,6 +4553,47 @@
         .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
   }
 
+  @Test
+  public void emailSubjectContainsChangeSizeBucket() throws Exception {
+    testEmailSubjectContainsChangeSizeBucket(0, "NoOp");
+    testEmailSubjectContainsChangeSizeBucket(1, "XS");
+    testEmailSubjectContainsChangeSizeBucket(9, "XS");
+    testEmailSubjectContainsChangeSizeBucket(10, "S");
+    testEmailSubjectContainsChangeSizeBucket(49, "S");
+    testEmailSubjectContainsChangeSizeBucket(50, "M");
+    testEmailSubjectContainsChangeSizeBucket(249, "M");
+    testEmailSubjectContainsChangeSizeBucket(250, "L");
+    testEmailSubjectContainsChangeSizeBucket(999, "L");
+    testEmailSubjectContainsChangeSizeBucket(1000, "XL");
+  }
+
+  private void testEmailSubjectContainsChangeSizeBucket(
+      int numberOfLines, String expectedSizeBucket) throws Exception {
+    String change;
+    if (numberOfLines == 0) {
+      // create empty change
+      ChangeInput in = new ChangeInput();
+      in.branch = Constants.MASTER;
+      in.subject = "Create a change from the API";
+      in.project = project.get();
+      ChangeInfo info = gApi.changes().create(in).get();
+      change = info.changeId;
+    } else {
+      change =
+          createChange(
+                  "subject",
+                  expectedSizeBucket + "-file-with-" + numberOfLines + "lines.txt",
+                  Collections.nCopies(numberOfLines, "line").stream().collect(joining("\n")))
+              .getChangeId();
+    }
+    sender.clear();
+    gApi.changes().id(change).addReviewer(user.email());
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(((StringEmailHeader) messages.get(0).headers().get("Subject")).getString())
+        .contains("[" + expectedSizeBucket + "]");
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index b1fb575..3531234 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
@@ -89,6 +90,7 @@
     @Inject protected RequestScopeOperations requestScopeOperations;
     @Inject protected ProjectOperations projectOperations;
     @Inject protected ExtensionRegistry extensionRegistry;
+    @Inject protected TestMetricMaker testMetricMaker;
 
     @FunctionalInterface
     protected interface RebaseCall {
@@ -771,6 +773,28 @@
 
       assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
     }
+
+    @Test
+    public void testCountRebasesMetric() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second change
+      testMetricMaker.reset();
+      rebaseCallWithInput.call(r2.getChangeId(), new RebaseInput());
+      // field1 is on_behalf_of_uploader, field2 is rebase_chain
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, false)).isEqualTo(1);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, false)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, true)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, true)).isEqualTo(0);
+    }
   }
 
   public static class RebaseViaRevisionApi extends Rebase {
@@ -1125,6 +1149,36 @@
       assertThat(thrown).hasMessageThat().contains("recursion not allowed");
     }
 
+    @Test
+    public void testCountRebasesMetric() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain.
+      testMetricMaker.reset();
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+      // field1 is on_behalf_of_uploader, field2 is rebase_chain
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, true)).isEqualTo(1);
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, false)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, true)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, false)).isEqualTo(0);
+    }
+
     private void verifyRebaseChainResponse(
         Response<RebaseChainInfo> res,
         boolean shouldHaveConflicts,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 7030804..5a5402b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestMetricMaker;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
@@ -70,6 +71,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private TestMetricMaker testMetricMaker;
 
   @Test
   public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
@@ -1021,6 +1023,95 @@
     assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isFalse();
   }
 
+  @Test
+  public void testSubmittedWithRebaserApprovalMetric() throws Exception {
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    allowVotingOnCodeReviewToAllUsers();
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+    // The change is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(1);
+  }
+
+  @Test
+  public void testCountRebasesMetric() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    testMetricMaker.reset();
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    // field1 is on_behalf_of_uploader, field2 is rebase_chain
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, false)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, true)).isEqualTo(0);
+  }
+
   private void allowPermissionToAllUsers(String permission) {
     allowPermission(permission, REGISTERED_USERS);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 016b1e6..6491202 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -140,6 +140,33 @@
     verifyCannotDeleteVote(true);
   }
 
+  @Test
+  public void deleteAlreadyDeletedVote_returnsNotFoundAndWithoutEmails() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    sender.clear();
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNoContent();
+    assertThat(sender.getMessages()).hasSize(1);
+
+    sender.clear();
+    response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNotFound();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
   private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index e44bfcf..cced47f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -984,7 +984,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .title(String.format("[S] Change in %s[master]: test commit", project));
+        .title(String.format("[XS] Change in %s[master]: test commit", project));
     assertThat(sender).didNotSend();
   }
 
diff --git a/modules/jgit b/modules/jgit
index 66b871b..596c445 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 66b871b777c1a58337e80dd03db68bb76b145a93
+Subproject commit 596c445af22ed9b6e0b7e35de44c127fcb8ecf7d
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index e3b3ad4..19e9d99 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -336,6 +336,12 @@
   lineNum: LineNumber;
 }
 
+export declare interface LineSelectedEventDetail {
+  number: LineNumber;
+  side: Side;
+  path?: string;
+}
+
 // TODO: Currently unused and not fired.
 export declare interface RenderProgressEventDetail {
   linesRendered: number;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index ce0ea02..4d0d3f1 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -24,7 +24,7 @@
   LabelNameToLabelTypeInfoMap,
   RepoName,
 } from '../../../types/common';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -34,18 +34,6 @@
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
-/**
- * Fired when the section has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a section that was previously added was removed.
- *
- * @event added-section-removed
- */
-
 const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
 
 // The name that gets automatically input when a new reference is added.
@@ -300,7 +288,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      fireEvent(this, 'access-modified');
+      fire(this, 'access-modified', {});
     }
     this.section.value.updatedId = this.section.id;
     this.requestUpdate();
@@ -432,11 +420,11 @@
       return;
     }
     if (this.section.value.added) {
-      fireEvent(this, 'added-section-removed');
+      fire(this, 'added-section-removed', {});
     }
     this.deleted = true;
     this.section.value.deleted = true;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   _handleUndoRemove() {
@@ -533,6 +521,10 @@
 
 declare global {
   interface HTMLElementEventMap {
+    /** Fired when the section has been modified or removed. */
+    'access-modified': CustomEvent<{}>;
+    /** Fired when a section that was previously added was removed. */
+    'added-section-removed': CustomEvent<{}>;
     'section-changed': ValueChangedEvent<PermissionAccessSection>;
   }
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 42ec988..61fe0c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -7,6 +7,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -68,22 +69,12 @@
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
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 3254b5c..b3f1e96 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
@@ -23,8 +23,8 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
@@ -38,6 +38,9 @@
   interface HTMLElementTagNameMap {
     'gr-create-change-dialog': GrCreateChangeDialog;
   }
+  interface HTMLElementEventMap {
+    'can-create-change': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-change-dialog')
@@ -125,7 +128,7 @@
               .text=${this.branch}
               .query=${this.query}
               placeholder="Destination branch"
-              @text-changed=${(e: CustomEvent) => {
+              @text-changed=${(e: ValueChangedEvent<BranchName>) => {
                 this.branch = e.detail.value;
               }}
             >
@@ -207,7 +210,7 @@
   }
 
   private allowCreate() {
-    fireEvent(this, 'can-create-change');
+    fire(this, 'can-create-change', {});
   }
 
   handleCreateChange(): Promise<void> {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 96688e9..893343f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -13,7 +13,7 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {createGroupUrl} from '../../../models/views/group';
 import {resolve} from '../../../models/dependency';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -22,6 +22,9 @@
   interface HTMLElementTagNameMap {
     'gr-create-group-dialog': GrCreateGroupDialog;
   }
+  interface HTMLElementEventMap {
+    'has-new-group-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-group-dialog')
@@ -75,7 +78,7 @@
   }
 
   private updateGroupName() {
-    fireEvent(this, 'has-new-group-name');
+    fire(this, 'has-new-group-name', {});
   }
 
   override focus() {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 558d571..12f36ec 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -13,13 +13,16 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
 import {RepoDetailView} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-pointer-dialog': GrCreatePointerDialog;
   }
+  interface HTMLElementEventMap {
+    'update-item-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-pointer-dialog')
@@ -113,7 +116,7 @@
   }
 
   private updateItemName() {
-    fireEvent(this, 'update-item-name');
+    fire(this, 'update-item-name', {});
   }
 
   handleCreateItem() {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index ed57830..1a70f2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -20,26 +20,25 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {createRepoUrl} from '../../../models/views/repo';
 import {resolve} from '../../../models/dependency';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {ValueChangedEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-repo-dialog': GrCreateRepoDialog;
   }
+  interface HTMLElementEventMap {
+    /** Fired when repostiory name is entered. */
+    'new-repo-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-repo-dialog')
 export class GrCreateRepoDialog extends LitElement {
-  /**
-   * Fired when repostiory name is entered.
-   *
-   * @event new-repo-name
-   */
-
   @query('input')
   input?: HTMLInputElement;
 
@@ -232,39 +231,41 @@
     return groups;
   }
 
-  private handleRightsTextChanged(e: CustomEvent) {
+  private handleRightsTextChanged(e: ValueChangedEvent) {
     this.repoConfig.parent = e.detail.value as RepoName;
     this.requestUpdate();
   }
 
-  private handleOwnerTextChanged(e: CustomEvent) {
+  private handleOwnerTextChanged(e: ValueChangedEvent) {
     this.repoOwner = e.detail.value;
   }
 
-  private handleOwnerValueChanged(e: CustomEvent) {
+  private handleOwnerValueChanged(e: ValueChangedEvent) {
     this.repoOwnerId = e.detail.value as GroupId;
   }
 
-  private handleNameBindValueChanged(e: CustomEvent) {
+  private handleNameBindValueChanged(e: ValueChangedEvent) {
     this.repoConfig.name = e.detail.value as RepoName;
     // nameChanged needs to be set before the event is fired,
     // because when the event is fired, gr-repo-list gets
     // the nameChanged value.
     this.nameChanged = !!e.detail.value;
-    fireEvent(this, 'new-repo-name');
+    fire(this, 'new-repo-name', {});
     this.requestUpdate();
   }
 
-  private handleBranchNameBindValueChanged(e: CustomEvent) {
+  private handleBranchNameBindValueChanged(e: ValueChangedEvent) {
     this.defaultBranch = e.detail.value as BranchName;
   }
 
-  private handleCreateEmptyCommitBindValueChanged(e: CustomEvent) {
+  private handleCreateEmptyCommitBindValueChanged(
+    e: ValueChangedEvent<boolean>
+  ) {
     this.repoConfig.create_empty_commit = e.detail.value;
     this.requestUpdate();
   }
 
-  private handlePermissionsOnlyBindValueChanged(e: CustomEvent) {
+  private handlePermissionsOnlyBindValueChanged(e: ValueChangedEvent<boolean>) {
     this.repoConfig.permissions_only = e.detail.value;
     this.requestUpdate();
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index af9977b..2fcec51 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -43,6 +43,7 @@
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ValueChangedEvent} from '../../../types/events';
 
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
@@ -546,22 +547,22 @@
       });
   }
 
-  private handleGroupMemberTextChanged(e: CustomEvent) {
+  private handleGroupMemberTextChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.groupMemberSearchName = e.detail.value;
   }
 
-  private handleGroupMemberValueChanged(e: CustomEvent) {
+  private handleGroupMemberValueChanged(e: ValueChangedEvent<number>) {
     if (this.loading) return;
     this.groupMemberSearchId = e.detail.value;
   }
 
-  private handleIncludedGroupTextChanged(e: CustomEvent) {
+  private handleIncludedGroupTextChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.includedGroupSearchName = e.detail.value;
   }
 
-  private handleIncludedGroupValueChanged(e: CustomEvent) {
+  private handleIncludedGroupValueChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.includedGroupSearchId = e.detail.value;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 0841595..30085e3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -25,7 +25,7 @@
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {EventType, PageErrorEvent} from '../../../types/events';
+import {PageErrorEvent} from '../../../types/events';
 import {getAccountSuggestions} from '../../../utils/account-util';
 import {getAppContext} from '../../../services/app-context';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -446,7 +446,7 @@
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
-    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    element.addEventListener('show-alert', alertStub);
     const errorResponse = {...new Response(), status: 404, ok: false};
     stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
       if (errFn !== undefined) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index ba2b3fd..1ec83efa8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -13,11 +13,11 @@
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {fire, firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {convertToString} from '../../../utils/string-util';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -48,16 +48,13 @@
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
+  interface HTMLElementEventMap {
+    'name-changed': CustomEvent<GroupNameChangedDetail>;
+  }
 }
 
 @customElement('gr-group')
 export class GrGroup extends LitElement {
-  /**
-   * Fired when the group name changes.
-   *
-   * @event name-changed
-   */
-
   private readonly query: AutocompleteQuery;
 
   @property({type: String})
@@ -373,13 +370,7 @@
         name: groupName,
         external: !this.groupIsInternal,
       };
-      this.dispatchEvent(
-        new CustomEvent('name-changed', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'name-changed', detail);
       this.requestUpdate();
     }
 
@@ -455,25 +446,25 @@
     return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
   }
 
-  private handleNameTextChanged(e: CustomEvent) {
+  private handleNameTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.name = e.detail.value as GroupName;
     this.requestUpdate();
   }
 
-  private handleOwnerTextChanged(e: CustomEvent) {
+  private handleOwnerTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.owner = e.detail.value;
     this.requestUpdate();
   }
 
-  private handleOwnerValueChanged(e: CustomEvent) {
+  private handleOwnerValueChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.groupConfigOwner = e.detail.value;
     this.requestUpdate();
   }
 
-  private handleDescriptionTextChanged(e: CustomEvent) {
+  private handleDescriptionTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.description = e.detail.value;
     this.requestUpdate();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 07b7c87..38123ad 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -34,7 +34,7 @@
   EditableRepoAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -64,16 +64,6 @@
   value: GroupInfo;
 }
 
-/**
- * Fired when the permission has been modified or removed.
- *
- * @event access-modified
- */
-/**
- * Fired when a permission that was previously added was removed.
- *
- * @event added-permission-removed
- */
 @customElement('gr-permission')
 export class GrPermission extends LitElement {
   @property({type: String})
@@ -361,7 +351,7 @@
     this.permission.value.modified = true;
     this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
     // Allows overall access page to know a change has been made.
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   handleRemovePermission() {
@@ -369,11 +359,11 @@
       return;
     }
     if (this.permission.value.added) {
-      fireEvent(this, 'added-permission-removed');
+      fire(this, 'added-permission-removed', {});
     }
     this.deleted = true;
     this.permission.value.deleted = true;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   private handleRulesChanged() {
@@ -542,7 +532,7 @@
     const value = this.rules[this.rules.length - 1].value;
     value!.added = true;
     this.permission.value.rules[groupId] = value!;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
     this.requestUpdate();
   }
 
@@ -565,6 +555,11 @@
     e.preventDefault();
   }
 
+  // TODO: Do not use generic `CustomEvent`.
+  // There is something fishy going on here though.
+  // `e.detail.value` is of type `Rule`, but `splice()` expects a `number`.
+  // Did not look closer, but this seems to be broken. Should `e.detail.value`
+  // be replaced by `1` maybe??
   private handleRuleChanged(e: CustomEvent, index: number) {
     this.rules!.splice(index, e.detail.value);
     this.handleRulesChanged();
@@ -574,6 +569,8 @@
 
 declare global {
   interface HTMLElementEventMap {
+    /** Fired when a permission that was previously added was removed. */
+    'added-permission-removed': CustomEvent<{}>;
     'permission-changed': ValueChangedEvent<
       PermissionArrayItem<EditablePermissionInfo>
     >;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 54a83ee..5afaa9d 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -15,21 +15,19 @@
 import {LitElement, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-option-changed': CustomEvent<PluginConfigOptionsChangedEventDetail>;
+  }
 }
 
 @customElement('gr-plugin-config-array-editor')
 export class GrPluginConfigArrayEditor extends LitElement {
-  /**
-   * Fired when the plugin config option changes.
-   *
-   * @event plugin-config-option-changed
-   */
-
   // private but used in test
   @state() newValue = '';
 
@@ -175,9 +173,7 @@
       info: {...info, values},
       notifyPath: `${_key}.values`,
     };
-    this.dispatchEvent(
-      new CustomEvent('plugin-config-option-changed', {detail})
-    );
+    fireNoBubbleNoCompose(this, 'plugin-config-option-changed', detail);
   }
 
   private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 52e0b3f..d00fa40 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -20,6 +20,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
+  AutocompleteCommitEvent,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
@@ -194,7 +195,7 @@
               id="editInheritFromInput"
               .text=${this.inheritFromFilter}
               .query=${this.query}
-              @commit=${(e: ValueChangedEvent) => {
+              @commit=${(e: AutocompleteCommitEvent) => {
                 this.handleUpdateInheritFrom(e);
               }}
               @bind-value-changed=${(e: ValueChangedEvent) => {
@@ -388,7 +389,7 @@
   }
 
   // private but used in test
-  handleUpdateInheritFrom(e: ValueChangedEvent) {
+  handleUpdateInheritFrom(e: AutocompleteCommitEvent) {
     this.inheritsFrom = {
       ...(this.inheritsFrom ?? {}),
       id: e.detail.value as UrlEncodedRepoName,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index dab2706..af2831a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -13,7 +13,7 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {EventType, PageErrorEvent} from '../../../types/events';
+import {PageErrorEvent} from '../../../types/events';
 import {RepoName} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -147,7 +147,7 @@
       handleSpy = sinon.spy(element, 'handleEditRepoConfig');
       alertStub = sinon.stub();
       element.repo = 'test' as RepoName;
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
     });
 
     test('successful creation of change', async () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index f8dcd32..2772519 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -25,8 +25,7 @@
   PluginOption,
 } from './gr-repo-plugin-config-types';
 import {paperStyles} from '../../../styles/gr-paper-styles';
-
-const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+import {fire} from '../../../utils/event-util';
 
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
@@ -255,14 +254,7 @@
       name,
       config: {...config, [_key]: info},
     };
-
-    this.dispatchEvent(
-      new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
-        detail,
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'plugin-config-changed', detail);
   }
 
   /**
@@ -277,4 +269,7 @@
   interface HTMLElementTagNameMap {
     'gr-repo-plugin-config': GrRepoPluginConfig;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-changed': CustomEvent<PluginConfigChangeDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 5f0a171..4e41dfe 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -8,28 +8,16 @@
 import '../../shared/gr-select/gr-select';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
-/**
- * Fired when the rule has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a rule that was previously added was removed.
- *
- * @event added-rule-removed
- */
-
 const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
 
 const Action = {
@@ -81,6 +69,11 @@
   interface HTMLElementTagNameMap {
     'gr-rule-editor': GrRuleEditor;
   }
+  interface HTMLElementEventMap {
+    /** Fired when a rule that was previously added was removed. */
+    'added-rule-removed': CustomEvent<{}>;
+    'rule-changed': ValueChangedEvent<Rule | undefined>;
+  }
 }
 
 @customElement('gr-rule-editor')
@@ -431,14 +424,14 @@
   private handleRemoveRule() {
     if (!this.rule?.value) return;
     if (this.rule.value.added) {
-      fireEvent(this, 'added-rule-removed');
+      fire(this, 'added-rule-removed', {});
     }
     this.deleted = true;
     this.rule.value.deleted = true;
 
     this.handleRuleChange();
 
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   private handleUndoRemove() {
@@ -476,7 +469,7 @@
     this.handleRuleChange();
 
     // Allows overall access page to know a change has been made.
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   // private but used in test
@@ -537,13 +530,6 @@
 
   private handleRuleChange() {
     this.requestUpdate('rule');
-
-    this.dispatchEvent(
-      new CustomEvent('rule-changed', {
-        detail: {value: this.rule},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'rule-changed', {value: this.rule});
   }
 }
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 11de37e..df63780 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
@@ -56,7 +56,7 @@
       <dialog id="actionModal" tabindex="-1">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
-          .disabled=${!this.isConfirmEnabled()}
+          .disabled=${this.isDisabled()}
           @confirm=${() => this.handleConfirm()}
           @cancel=${() => this.handleClose()}
           .cancelLabel=${'Close'}
@@ -104,13 +104,13 @@
     );
   }
 
-  private isConfirmEnabled() {
+  private isDisabled() {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
     for (const status of this.progress.values()) {
-      if (status !== ProgressStatus.NOT_STARTED) return false;
+      if (status !== ProgressStatus.NOT_STARTED) return true;
     }
-    return true;
+    return false;
   }
 
   private isCancelEnabled() {
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 a9b8028..db82523 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
@@ -161,7 +161,9 @@
       <dialog id="actionModal" tabindex="-1">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
-          .disabled=${!this.isConfirmEnabled()}
+          .disabled=${this.isDisabled(
+            triggerLabels.length + nonTriggerLabels.length
+          )}
           ?loading=${this.isLoading()}
           .loadingLabel=${'Voting in progress...'}
           @confirm=${() => this.handleConfirm()}
@@ -289,11 +291,12 @@
     return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
   }
 
-  private isConfirmEnabled() {
+  private isDisabled(permittedLabelsCount: number) {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
-    return (
-      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+    return !(
+      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED &&
+      permittedLabelsCount > 0
     );
   }
 
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 654ed91..8a5bf47 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
@@ -307,17 +307,18 @@
     );
 
     // No common label with change1 so button is disabled
-    change2.labels = {
+    const c2 = {...change2}; // create copy so other tests are not affected
+    c2.labels = {
       x: {value: null} as LabelInfo,
       y: {value: null} as LabelInfo,
       z: {value: null} as LabelInfo,
     };
-    change2.submit_requirements = [
+    c2.submit_requirements = [
       createSubmitRequirementResultInfo('label:x=MAX'),
       createSubmitRequirementResultInfo('label:y=MAX'),
       createSubmitRequirementResultInfo('label:z=MAX'),
     ];
-    changes.push({...change2});
+    changes.push({...c2});
     getChangesStub.restore();
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
@@ -484,6 +485,45 @@
       assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
     });
 
+    test('button is disabled if no votes are possible', async () => {
+      const c2 = {...change2}; // create copy so other tests are not affected
+      c2.labels = {
+        x: {value: null} as LabelInfo,
+        y: {value: null} as LabelInfo,
+        z: {value: null} as LabelInfo,
+      };
+      c2.submit_requirements = [
+        createSubmitRequirementResultInfo('label:x=MAX'),
+        createSubmitRequirementResultInfo('label:y=MAX'),
+        createSubmitRequirementResultInfo('label:z=MAX'),
+      ];
+
+      const changes: ChangeInfo[] = [change1, c2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      stubRestApi('saveChangeReview').callsFake(
+        (_changeNum, _patchNum, _review, errFn) =>
+          Promise.resolve(new Response()).then(res => {
+            errFn && errFn();
+            return res;
+          })
+      );
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(c2);
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm')
+          .disabled
+      );
+    });
+
     test('closing dialog does not trigger reload if no request made', async () => {
       const changes: ChangeInfo[] = [change1, change2];
       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
index f8fcad8..ea61bfc 100644
--- 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
@@ -159,7 +159,7 @@
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
         .verticalOffset=${24}
-        @opened-changed=${(e: CustomEvent) =>
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
           (this.isDropdownOpen = e.detail.value)}
       >
         ${when(
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
index f7a2531..0cc3e18 100644
--- 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
@@ -29,7 +29,6 @@
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
-import {EventType} from '../../../types/events';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-hashtag-flow';
@@ -303,7 +302,7 @@
 
     test('add hashtag from selected change', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "hashtag1"
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -377,7 +376,7 @@
 
     test('add multiple hashtag from selected change', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "hashtag1"
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -425,7 +424,7 @@
 
     test('add existing hashtag not on selected changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       const getHashtagsStub = stubRestApi(
         'getChangesWithSimilarHashtag'
@@ -481,7 +480,7 @@
 
     test('add new hashtag', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       const getHashtagsStub = stubRestApi(
         'getChangesWithSimilarHashtag'
@@ -586,7 +585,7 @@
 
     test('cannot add existing hashtag already on selected changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "sharedHashtag"
       queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
       await element.updateComplete;
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 7752476..4a01412 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
@@ -159,7 +159,7 @@
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
         .verticalOffset=${24}
-        @opened-changed=${(e: CustomEvent) =>
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
           (this.isDropdownOpen = e.detail.value)}
       >
         ${when(
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 9125cfd..d2dced2 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
@@ -29,7 +29,6 @@
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
-import {EventType} from '../../../types/events';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-topic-flow';
@@ -326,7 +325,7 @@
 
     test('remove single topic', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -387,7 +386,7 @@
 
     test('shows error when remove topic fails', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -435,7 +434,7 @@
 
     test('applies topic to all changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -589,7 +588,7 @@
 
     test('create new topic', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
         []
       );
@@ -639,7 +638,7 @@
 
     test('shows error when create topic fails', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
         []
       );
@@ -682,7 +681,7 @@
         {...createChange(), topic: 'foo' as TopicName},
       ]);
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const autocomplete = queryAndAssert<GrAutocomplete>(
         element,
         'gr-autocomplete'
@@ -732,7 +731,7 @@
         {...createChange(), topic: 'foo' as TopicName},
       ]);
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const autocomplete = queryAndAssert<GrAutocomplete>(
         element,
         'gr-autocomplete'
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 1c86354..b5593f3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -15,7 +15,7 @@
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fire, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
@@ -316,7 +316,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 748c2b8..6c7e661 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -17,7 +17,7 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {fire, fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireReload} from '../../../utils/event-util';
 import {ColumnNames, ScrollMode} from '../../../constants/constants';
 import {getRequirements} from '../../../utils/label-util';
 import {Key} from '../../../utils/dom-util';
@@ -77,18 +77,6 @@
 @customElement('gr-change-list')
 export class GrChangeList extends LitElement {
   /**
-   * Fired when next page key shortcut was pressed.
-   *
-   * @event next-page
-   */
-
-  /**
-   * Fired when previous page key shortcut was pressed.
-   *
-   * @event previous-page
-   */
-
-  /**
    * The logged-in user's account, or an empty object if no user is logged
    * in.
    */
@@ -415,11 +403,11 @@
   }
 
   private nextPage() {
-    fireEvent(this, 'next-page');
+    fire(this, 'next-page', {});
   }
 
   private prevPage() {
-    fireEvent(this, 'previous-page');
+    fire(this, 'previous-page', {});
   }
 
   private refreshChangeList() {
@@ -484,5 +472,9 @@
   }
   interface HTMLElementEventMap {
     'selected-index-changed': ValueChangedEvent<number>;
+    /** Fired when next page key shortcut was pressed. */
+    'next-page': CustomEvent<{}>;
+    /** Fired when previous page key shortcut was pressed. */
+    'previous-page': CustomEvent<{}>;
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 9c53fea..d9be9ca 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement} from 'lit/decorators.js';
@@ -14,6 +14,10 @@
   interface HTMLElementTagNameMap {
     'gr-create-change-help': GrCreateChangeHelp;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the "Create change" button is tapped. */
+    'create-tap': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-change-help')
@@ -87,11 +91,8 @@
     `;
   }
 
-  /**
-   * Fired when the "Create change" button is tapped.
-   */
   _handleCreateTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'create-tap');
+    fire(this, 'create-tap', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 561fffd..16220ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -12,6 +12,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 export interface CreateDestinationConfirmDetail {
   repo?: RepoName;
@@ -88,7 +89,7 @@
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+    fireNoBubbleNoCompose(this, 'confirm-destination', detail);
   };
 }
 
@@ -96,4 +97,7 @@
   interface HTMLElementTagNameMap {
     'gr-create-destination-dialog': GrCreateDestinationDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-destination': CustomEvent<CreateDestinationConfirmDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index cd30440..8c99aaa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -29,7 +29,7 @@
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {
   fireAlert,
-  fireEvent,
+  fire,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
@@ -241,7 +241,9 @@
       </dialog>
       <gr-create-destination-dialog
         id="destinationDialog"
-        @confirm=${(e: CustomEvent<CreateDestinationConfirmDetail>) => {
+        @confirm-destination=${(
+          e: CustomEvent<CreateDestinationConfirmDetail>
+        ) => {
           this.handleDestinationConfirm(e);
         }}
       ></gr-create-destination-dialog>
@@ -537,7 +539,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-dashboard');
     }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e47b450..36ac307 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -76,7 +76,8 @@
 import {
   fire,
   fireAlert,
-  fireEvent,
+  fireError,
+  fireNoBubbleNoCompose,
   fireReload,
 } from '../../../utils/event-util';
 import {
@@ -84,7 +85,6 @@
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
   ActionType,
@@ -334,18 +334,6 @@
    * @event custom-tap - naming pattern: <action key>-tap
    */
 
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when a change action fails.
-   *
-   * @event show-error
-   */
-
   @query('#mainContent') mainContent?: Element;
 
   @query('#actionsModal') actionsModal?: HTMLDialogElement;
@@ -671,7 +659,7 @@
           id="confirmRebase"
           class="confirmDialog"
           .changeNumber=${this.change?._number}
-          @confirm=${this.handleRebaseConfirm}
+          @confirm-rebase=${this.handleRebaseConfirm}
           @cancel=${this.handleConfirmDialogCancel}
           .disableActions=${this.inProgressActionKeys.has(
             RevisionActions.REBASE
@@ -708,7 +696,7 @@
         <gr-confirm-revert-dialog
           id="confirmRevertDialog"
           class="confirmDialog"
-          @confirm=${this.handleRevertDialogConfirm}
+          @confirm-revert=${this.handleRevertDialogConfirm}
           @cancel=${this.handleConfirmDialogCancel}
         ></gr-confirm-revert-dialog>
         <gr-confirm-abandon-dialog
@@ -1912,13 +1900,7 @@
   ) {
     if (!response) {
       return Promise.resolve(() => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message: `Could not perform action '${action.__key}'`},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireError(this, `Could not perform action '${action.__key}'`);
       });
     }
     if (action && action.__key === RevisionActions.CHERRYPICK) {
@@ -1936,13 +1918,7 @@
       }
     }
     return response.text().then(errText => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          detail: {message: `Could not perform action: ${errText}`},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireError(this, `Could not perform action: ${errText}`);
       if (!errText.startsWith('Change is already up to date')) {
         throw Error(errText);
       }
@@ -1973,19 +1949,13 @@
       .fetchChangeUpdates(change)
       .then(result => {
         if (!result.isLatest) {
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message:
-                  'Cannot set label: a newer patch has been ' +
-                  'uploaded to this change.',
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, 'show-alert', {
+            message:
+              'Cannot set label: a newer patch has been ' +
+              'uploaded to this change.',
+            action: 'Reload',
+            callback: () => fireReload(this, true),
+          });
 
           // Because this is not a network error, call the cleanup function
           // but not the error handler.
@@ -2047,12 +2017,12 @@
 
   // private but used in test
   handleDownloadTap() {
-    fireEvent(this, 'download-tap');
+    fire(this, 'download-tap', {});
   }
 
   // private but used in test
   handleIncludedInTap() {
-    fireEvent(this, 'included-tap');
+    fire(this, 'included-tap', {});
   }
 
   // private but used in test
@@ -2241,17 +2211,21 @@
   }
 
   private handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'edit-tap', {});
   }
 
   private handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
 }
 
 declare global {
   interface HTMLElementEventMap {
+    'download-tap': CustomEvent<{}>;
+    'edit-tap': CustomEvent<{}>;
+    'included-tap': CustomEvent<{}>;
     'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+    'stop-edit-tap': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-actions': GrChangeActions;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 4602eac..82900a3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -54,7 +54,6 @@
 import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -1456,7 +1455,7 @@
           enabled: true,
         };
         queryAndAssert(element, 'gr-confirm-revert-dialog').dispatchEvent(
-          new CustomEvent('confirm', {
+          new CustomEvent('confirm-revert', {
             detail: {
               message: 'foo message',
               revertType: 1,
@@ -2440,7 +2439,7 @@
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
-        element.addEventListener(EventType.SHOW_ALERT, onShowAlert);
+        element.addEventListener('show-alert', onShowAlert);
       });
 
       suite('happy path', () => {
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 b9a04bd..0e71c1f 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
@@ -57,7 +57,7 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
   isDefined,
@@ -769,7 +769,7 @@
     } finally {
       this.settingTopic = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
@@ -802,7 +802,7 @@
     await this.restApiService.setChangeHashtag(this.change._number, {
       add: [newHashtag as Hashtag],
     });
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
@@ -947,7 +947,7 @@
     return createSearchUrl({hashtag, statuses: ['open', 'merged']});
   }
 
-  private async handleTopicRemoved(e: CustomEvent) {
+  private async handleTopicRemoved(e: Event) {
     assertIsDefined(this.change, 'change');
     const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
@@ -957,12 +957,12 @@
     } finally {
       target.disabled = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
   // private but used in test
-  async handleHashtagRemoved(e: CustomEvent) {
+  async handleHashtagRemoved(e: Event) {
     e.preventDefault();
     assertIsDefined(this.change, 'change');
     const target = e.target as GrLinkedChip;
@@ -975,7 +975,7 @@
     } finally {
       target.disabled = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index e45de51..c46fe24 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -54,7 +54,6 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {nothing} from 'lit';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
@@ -871,7 +870,7 @@
         Promise.resolve(newTopic)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
 
@@ -892,7 +891,7 @@
         Promise.resolve(newTopic)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const remove = queryAndAssert<GrButton>(chip, '#remove');
@@ -916,7 +915,7 @@
         Promise.resolve(newHashtag)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       element.handleHashtagChanged(
         new CustomEvent('test', {detail: 'new hashtag'})
       );
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 bb27237..2246671 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
@@ -116,9 +116,9 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {
   EditableContentSaveEvent,
-  EventType,
+  FileActionTapEvent,
   OpenFixPreviewEvent,
-  ShowAlertEventDetail,
+  ShowReplyDialogEvent,
   SwitchTabEvent,
   TabState,
   ValueChangedEvent,
@@ -129,7 +129,7 @@
 import {
   fireAlert,
   fireDialogChange,
-  fireEvent,
+  fire,
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
@@ -589,11 +589,9 @@
     this.addEventListener('editable-content-cancel', () =>
       this.handleCommitMessageCancel()
     );
-    this.addEventListener(EventType.OPEN_FIX_PREVIEW, e =>
-      this.onOpenFixPreview(e)
-    );
+    this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
 
-    this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e));
+    this.addEventListener('show-tab', e => this.setActiveTab(e));
     this.addEventListener('reload', e => {
       this.loadData(
         /* isLocationChange= */ false,
@@ -1193,7 +1191,6 @@
         <gr-download-dialog
           id="downloadDialog"
           .change=${this.change}
-          .patchNum=${this.patchRange?.patchNum}
           .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
@@ -1534,8 +1531,6 @@
           .editMode=${this.getEditMode()}
           .loggedIn=${this.loggedIn}
           .shownFileCount=${this.shownFileCount}
-          .patchNum=${this.patchRange?.patchNum}
-          .basePatchNum=${this.patchRange?.basePatchNum}
           .filesExpanded=${this.fileList?.filesExpanded}
           @open-diff-prefs=${this.handleOpenDiffPrefs}
           @open-download-dialog=${this.handleOpenDownloadDialog}
@@ -1547,7 +1542,6 @@
           id="fileList"
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .patchRange=${this.patchRange}
           .editMode=${this.getEditMode()}
           @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
             this.shownFileCount = e.detail.length;
@@ -2012,7 +2006,7 @@
   }
 
   // Private but used in tests.
-  handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+  handleShowReplyDialog(e: ShowReplyDialogEvent) {
     let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
       target = FocusTarget.CCS;
@@ -2167,7 +2161,7 @@
     } else if (this.viewState?.commentId) {
       tab = Tab.COMMENT_THREADS;
     }
-    this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
+    this.setActiveTab(new CustomEvent('show-tab', {detail: {tab}}));
   }
 
   // Private but used in tests.
@@ -2358,7 +2352,7 @@
 
   private handleOpenReplyDialog() {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return;
     }
     this.openReplyDialog(FocusTarget.ANY);
@@ -2388,7 +2382,7 @@
           reason
         )
         .then(() => {
-          fireEvent(this, 'hide-alert');
+          fire(this, 'hide-alert', {});
         });
     } else {
       const reason = getAddedByReason(this.account, this.serverConfig);
@@ -2405,7 +2399,7 @@
           reason
         )
         .then(() => {
-          fireEvent(this, 'hide-alert');
+          fire(this, 'hide-alert', {});
         });
     }
     this.change = newChange;
@@ -2873,7 +2867,7 @@
     allDataPromises.push(mergeabilityLoaded);
 
     coreDataPromise.then(() => {
-      fireEvent(this, 'change-details-loaded');
+      fire(this, 'change-details-loaded', {});
       this.reporting.timeEnd(Timing.CHANGE_RELOAD);
       if (isLocationChange) {
         this.reporting.changeDisplayed(roleDetails(this.change, this.account));
@@ -3058,20 +3052,14 @@
           }
 
           this.cancelUpdateCheckTimer();
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message: toastMessage,
-                // Persist this alert.
-                dismissOnNavigation: true,
-                showDismiss: true,
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, 'show-alert', {
+            message: toastMessage,
+            // Persist this alert.
+            dismissOnNavigation: true,
+            showDismiss: true,
+            action: 'Reload',
+            callback: () => fireReload(this, true),
+          });
         });
     }, this.serverConfig.change.update_delay * 1000);
   }
@@ -3100,7 +3088,7 @@
     return classes.join(' ');
   }
 
-  private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+  private handleFileActionTap(e: FileActionTapEvent) {
     e.preventDefault();
     assertIsDefined(this.fileListHeader);
     const controls =
@@ -3220,7 +3208,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
   }
 
   private getRevisionInfo(): RevisionInfoClass | undefined {
@@ -3248,6 +3236,7 @@
 declare global {
   interface HTMLElementEventMap {
     'toggle-star': CustomEvent<ChangeStarToggleStarDetail>;
+    'change-details-loaded': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-view': GrChangeView;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index ac4c1f9..34d11a3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1772,7 +1772,7 @@
     const openStub = sinon.stub(element, 'openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
-      detail: {value: {ccsOnly: false}},
+      detail: {value: {reviewersOnly: true, ccsOnly: false}},
     });
     element.handleShowReplyDialog(e);
     assert(
@@ -1781,7 +1781,7 @@
     );
     assert.equal(openStub.callCount, 1);
 
-    e.detail.value = {ccsOnly: true};
+    e.detail.value = {reviewersOnly: false, ccsOnly: true};
     element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.CCS),
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 85746df..2129918 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -14,6 +14,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -132,25 +133,14 @@
 
   // private but used in test
   confirm() {
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   // private but used in test
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   private handleBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 02156df..7723327 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -7,6 +7,7 @@
 import {customElement} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
@@ -66,23 +67,13 @@
   handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 5f3b824..4d2602e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -30,7 +30,7 @@
   ChangeStatus,
   ProgressStatus,
 } from '../../../constants/constants';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireNoBubble} from '../../../utils/event-util';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {choose} from 'lit/directives/choose.js';
@@ -503,12 +503,12 @@
 
   private handlecherryPickSingleChangeClicked() {
     this.cherryPickType = CherryPickType.SINGLE_CHANGE;
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
   private handlecherryPickTopicClicked() {
     this.cherryPickType = CherryPickType.TOPIC;
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
   private computeMessage() {
@@ -605,23 +605,13 @@
       return;
     }
     // Cherry pick single change
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   resetFocus() {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 3f84189..6f82e8c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -15,6 +15,7 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireNoBubble} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
@@ -142,23 +143,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   private getProjectBranchesSuggestions(input: string) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index c2739f3..34869f2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -22,7 +22,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 export interface RebaseChange {
   name: string;
@@ -99,8 +99,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     this.query = input => this.getChangeSuggestions(input);
@@ -234,8 +232,7 @@
             >
           </div>
           ${when(
-            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN) &&
-              this.hasParent,
+            this.hasParent,
             () =>
               html`<div>
                 <input
@@ -355,14 +352,14 @@
       allowConflicts: this.rebaseAllowConflicts.checked,
       rebaseChain: !!this.rebaseChain?.checked,
     };
-    this.dispatchEvent(new CustomEvent('confirm', {detail}));
+    fireNoBubbleNoCompose(this, 'confirm-rebase', detail);
     this.text = '';
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel'));
+    fireNoBubbleNoCompose(this, 'cancel', {});
     this.text = '';
   }
 
@@ -398,4 +395,7 @@
   interface HTMLElementTagNameMap {
     'gr-confirm-rebase-dialog': GrConfirmRebaseDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-rebase': CustomEvent<ConfirmRebaseEventDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index dd9a5ee..fedc377 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -30,21 +30,6 @@
   message?: string;
 }
 
-export interface CancelRevertEventDetail {
-  revertType: RevertType;
-}
-
-declare global {
-  interface HTMLElementEventMap {
-    /** Fired when the confirm button is pressed. */
-    // prettier-ignore
-    'confirm': CustomEvent<ConfirmRevertEventDetail>;
-    /** Fired when the cancel button is pressed. */
-    // prettier-ignore
-    'cancel': CustomEvent<CancelRevertEventDetail>;
-  }
-}
-
 @customElement('gr-confirm-revert-dialog')
 export class GrConfirmRevertDialog
   extends LitElement
@@ -302,16 +287,13 @@
       revertType: this.revertType,
       message: this.message,
     };
-    fire(this, 'confirm', detail);
+    fire(this, 'confirm-revert', detail);
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    const detail: ConfirmRevertEventDetail = {
-      revertType: this.revertType,
-    };
-    fire(this, 'cancel', detail);
+    fire(this, 'cancel', {});
   }
 }
 
@@ -319,4 +301,7 @@
   interface HTMLElementTagNameMap {
     'gr-confirm-revert-dialog': GrConfirmRevertDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-revert': CustomEvent<ConfirmRevertEventDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 38309f2..904285f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -7,7 +7,6 @@
 import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
 import {ChangeSubmissionId, CommitId} from '../../../types/common';
-import {EventType} from '../../../types/events';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
@@ -47,7 +46,7 @@
   test('no match', () => {
     assert.isNotOk(element.message);
     const alertStub = sinon.stub();
-    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
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 1b5f171..b5297fd 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
@@ -21,6 +21,7 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 @customElement('gr-confirm-submit-dialog')
 export class GrConfirmSubmitDialog
@@ -193,13 +194,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'cancel', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index d291ebb..11dc890 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -9,7 +9,7 @@
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
@@ -17,6 +17,9 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -38,15 +41,21 @@
   @property({type: Object})
   config?: DownloadInfo;
 
-  @property({type: String})
-  patchNum: PatchSetNum | undefined;
+  @state() patchNum?: PatchSetNum;
 
   @state() private selectedScheme?: string;
 
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
     for (const key of ['1', '2', '3', '4', '5']) {
       this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
@@ -224,7 +233,7 @@
       commands[index].command,
       `${commands[index].title} command`
     );
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   override focus() {
@@ -314,7 +323,7 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   private schemesChanged() {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index c1e866c..69297f6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -25,7 +25,7 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
@@ -40,25 +40,11 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {createChangeUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {PatchRangeChangeEvent} from '../../diff/gr-patch-range-select/gr-patch-range-select';
 
 @customElement('gr-file-list-header')
 export class GrFileListHeader extends LitElement {
-  /**
-   * @event expand-diffs
-   */
-
-  /**
-   * @event collapse-diffs
-   */
-
-  /**
-   * @event open-diff-prefs
-   */
-
-  /**
-   * @event open-download-dialog
-   */
-
   @property({type: Object})
   account: AccountInfo | undefined;
 
@@ -84,14 +70,12 @@
   shownFileCount = 0;
 
   @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: String})
-  basePatchNum?: BasePatchSetNum;
-
-  @property({type: String})
   filesExpanded?: FilesExpandedState;
 
+  @state() patchNum?: PatchSetNum;
+
+  @state() basePatchNum?: BasePatchSetNum;
+
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
@@ -119,6 +103,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -136,6 +122,16 @@
         this.serverConfig = config;
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      x => (this.basePatchNum = x)
+    );
   }
 
   static override styles = [
@@ -366,11 +362,11 @@
   }
 
   private expandAllDiffs() {
-    fireEvent(this, 'expand-diffs');
+    fire(this, 'expand-diffs', {});
   }
 
   private collapseAllDiffs() {
-    fireEvent(this, 'collapse-diffs');
+    fire(this, 'collapse-diffs', {});
   }
 
   private computeExpandedClass(filesExpanded?: FilesExpandedState) {
@@ -392,7 +388,7 @@
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  handlePatchChange(e: CustomEvent) {
+  handlePatchChange(e: PatchRangeChangeEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
       (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
@@ -407,15 +403,13 @@
 
   private handlePrefsTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'open-diff-prefs');
+    fire(this, 'open-diff-prefs', {});
   }
 
   private handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('open-download-dialog', {bubbles: false})
-    );
+    fireNoBubbleNoCompose(this, 'open-download-dialog', {});
   }
 
   private computeEditModeClass(editMode?: boolean) {
@@ -439,4 +433,10 @@
   interface HTMLElementTagNameMap {
     'gr-file-list-header': GrFileListHeader;
   }
+  interface HTMLElementEventMap {
+    'collapse-diffs': CustomEvent<{}>;
+    'expand-diffs': CustomEvent<{}>;
+    'open-diff-prefs': CustomEvent<{}>;
+    'open-download-dialog': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 6eaf7ae..ad75253 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -42,6 +42,7 @@
   NumericChangeId,
   PARENT,
   PatchRange,
+  RevisionPatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -173,19 +174,24 @@
 }
 @customElement('gr-file-list')
 export class GrFileList extends LitElement {
-  /**
-   * @event files-expanded-changed
-   * @event files-shown-changed
-   * @event diff-prefs-changed
-   */
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  @property({type: Object})
-  patchRange?: PatchRange;
+  get patchRange(): PatchRange | undefined {
+    if (!this.patchNum) return undefined;
+    return {
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+    };
+  }
 
-  @property({type: String})
-  patchNum?: string;
+  // Private but used in tests.
+  @state()
+  patchNum?: RevisionPatchSetNum;
+
+  // Private but used in tests.
+  @state()
+  basePatchNum: BasePatchSetNum = PARENT;
 
   @property({type: Number})
   changeNum?: NumericChangeId;
@@ -811,6 +817,16 @@
         this.reviewed = reviewedFiles ?? [];
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      x => (this.basePatchNum = x)
+    );
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
@@ -1136,7 +1152,7 @@
     const hasExtendedStatus = this.filesLeftBase.length > 0;
     // no file means "header row"
     if (!file) {
-      const psNum = this.patchRange?.patchNum;
+      const psNum = this.patchNum;
       return hasExtendedStatus
         ? this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)
         : nothing;
@@ -1154,8 +1170,8 @@
     const status = fileIsReverted
       ? FileInfoStatus.REVERTED
       : file?.status ?? FileInfoStatus.MODIFIED;
-    const left = `patchset ${this.patchRange?.basePatchNum}`;
-    const right = `patchset ${this.patchRange?.patchNum}`;
+    const left = `patchset ${this.basePatchNum}`;
+    const right = `patchset ${this.patchNum}`;
     const postfix = ` between ${left} and ${right}`;
 
     return html`<gr-file-status
@@ -1175,7 +1191,7 @@
       ></gr-icon>
     `;
     // no path means "header row"
-    const psNum = this.patchRange?.basePatchNum;
+    const psNum = this.basePatchNum;
     if (!path) {
       return html`
         ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)} ${arrow}
@@ -1187,7 +1203,7 @@
 
     const status = file.status ?? FileInfoStatus.MODIFIED;
     const left = 'base';
-    const right = `patchset ${this.patchRange?.basePatchNum}`;
+    const right = `patchset ${this.basePatchNum}`;
     const postfix = ` between ${left} and ${right}`;
 
     return html`
@@ -1664,16 +1680,16 @@
     if (
       this.change &&
       this.changeNum &&
-      this.patchRange?.patchNum &&
-      new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
-      this.patchRange.basePatchNum === PARENT &&
-      this.patchRange.patchNum !== EDIT
+      this.patchNum &&
+      new RevisionInfo(this.change).isMergeCommit(this.patchNum) &&
+      this.basePatchNum === PARENT &&
+      this.patchNum !== EDIT
     ) {
       const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
         this.changeNum,
         {
           basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
-          patchNum: this.patchRange.patchNum,
+          patchNum: this.patchNum,
         }
       );
       if (!allFilesByPath) return;
@@ -2252,13 +2268,7 @@
     const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
     const filesShown = this.files.slice(0, this.numFilesShown);
-    this.dispatchEvent(
-      new CustomEvent('files-shown-changed', {
-        detail: {length: filesShown.length},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'files-shown-changed', {length: filesShown.length});
 
     // Start the timer for the rendering work here because this is where the
     // shownFiles property is being set, and shownFiles is used in the
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index c79be96..8fb5622 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -102,10 +102,8 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       element.numFilesShown = 200;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       saveStub = sinon
         .stub(element, '_saveReviewedState')
         .callsFake(() => Promise.resolve());
@@ -365,7 +363,7 @@
     test('renders file status column header', async () => {
       element.files = createFiles(1, {lines_inserted: 9});
       element.filesLeftBase = createFiles(1, {lines_inserted: 9});
-      element.patchRange!.basePatchNum = 1 as PatchSetNumber;
+      element.basePatchNum = 1 as PatchSetNumber;
       await element.updateComplete;
       const fileRows = queryAll<HTMLDivElement>(element, '.header-row');
       const statusCol = queryAndAssert(fileRows?.[0], '.status');
@@ -695,22 +693,9 @@
 
     test('comment filtering', () => {
       element.changeComments = createChangeComments();
-      const parentTo1 = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
 
-      const parentTo2 = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
-
-      const _1To2 = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
-
-      element.patchRange = parentTo1;
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
@@ -719,7 +704,9 @@
         }),
         '2c'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
@@ -728,7 +715,9 @@
         }),
         '3c'
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: 'unresolved.file',
@@ -737,7 +726,9 @@
         }),
         '1 draft'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: 'unresolved.file',
@@ -746,7 +737,9 @@
         }),
         '1 draft'
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'unresolved.file',
@@ -755,7 +748,9 @@
         }),
         '1d'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'unresolved.file',
@@ -764,7 +759,9 @@
         }),
         '1d'
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'myfile.txt',
@@ -773,7 +770,9 @@
         }),
         '1c'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'myfile.txt',
@@ -782,16 +781,9 @@
         }),
         '3c'
       );
-      element.patchRange = parentTo1;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: 'myfile.txt',
@@ -801,7 +793,19 @@
         ''
       );
 
-      element.patchRange = parentTo1;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
@@ -810,7 +814,9 @@
         }),
         ''
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
@@ -819,7 +825,9 @@
         }),
         ''
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
@@ -828,7 +836,9 @@
         }),
         ''
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
@@ -837,7 +847,9 @@
         }),
         ''
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
@@ -846,7 +858,9 @@
         }),
         ''
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
@@ -855,7 +869,9 @@
         }),
         ''
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
@@ -864,7 +880,9 @@
         }),
         ''
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
@@ -873,7 +891,9 @@
         }),
         ''
       );
-      element.patchRange = parentTo2;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
@@ -882,7 +902,9 @@
         }),
         '1c'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
@@ -891,7 +913,9 @@
         }),
         '3c'
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: '/COMMIT_MSG',
@@ -900,7 +924,9 @@
         }),
         '2 drafts'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsString({
           __path: '/COMMIT_MSG',
@@ -909,7 +935,9 @@
         }),
         '2 drafts'
       );
-      element.patchRange = parentTo1;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
@@ -918,7 +946,9 @@
         }),
         '2d'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
@@ -927,7 +957,9 @@
         }),
         '2d'
       );
-      element.patchRange = parentTo2;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'myfile.txt',
@@ -936,7 +968,9 @@
         }),
         '2c'
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeCommentsStringMobile({
           __path: 'myfile.txt',
@@ -945,7 +979,9 @@
         }),
         '3c'
       );
-      element.patchRange = parentTo2;
+
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
@@ -954,7 +990,9 @@
         }),
         ''
       );
-      element.patchRange = _1To2;
+
+      element.basePatchNum = 1 as BasePatchSetNum;
+      element.patchNum = 2 as RevisionPatchSetNum;
       assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
@@ -973,10 +1011,8 @@
           normalize({}, 'myfile.txt'),
         ];
         element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 2 as RevisionPatchSetNum,
-        };
+        element.basePatchNum = PARENT;
+        element.patchNum = 2 as RevisionPatchSetNum;
         element.change = {
           _number: 42 as NumericChangeId,
           project: 'test-project',
@@ -1197,10 +1233,8 @@
         normalize({}, 'myfile.txt'),
       ];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       element.fileCursor.setCursorAtIndex(0);
 
       const reviewSpy = sinon.spy(element, 'reviewFile');
@@ -1263,10 +1297,8 @@
         normalize({}, 'f2.txt'),
       ];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       await element.updateComplete;
 
       const clickSpy = sinon.spy(element, 'handleFileListClick');
@@ -1303,10 +1335,8 @@
         normalize({}, 'f2.txt'),
       ];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       element.editMode = true;
       await element.updateComplete;
 
@@ -1324,10 +1354,8 @@
     test('checkbox shows/hides diff inline', async () => {
       element.files = [normalize({}, 'myfile.txt')];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       element.fileCursor.setCursorAtIndex(0);
       sinon.stub(element, 'expandedFilesChanged');
       await element.updateComplete;
@@ -1353,10 +1381,8 @@
     test('diff mode correctly toggles the diffs', async () => {
       element.files = [normalize({}, 'myfile.txt')];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       const updateDiffPrefSpy = sinon.spy(element, 'updateDiffPreferences');
       element.fileCursor.setCursorAtIndex(0);
       await element.updateComplete;
@@ -1383,10 +1409,8 @@
     test('tapping row ignores links', async () => {
       element.files = [normalize({}, '/COMMIT_MSG')];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       sinon.stub(element, 'expandedFilesChanged');
       await element.updateComplete;
       const commitMsgFile = queryAll<HTMLAnchorElement>(
@@ -1680,10 +1704,8 @@
         };
         element.changeNum = changeWithMultipleParents._number;
         element.change = changeWithMultipleParents;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 1 as RevisionPatchSetNum,
-        };
+        element.basePatchNum = PARENT;
+        element.patchNum = 1 as RevisionPatchSetNum;
         await element.updateComplete;
         await waitEventLoop();
       });
@@ -1744,10 +1766,8 @@
       });
 
       test('not shown for non-Auto Merge base parents', async () => {
-        element.patchRange = {
-          basePatchNum: 1 as BasePatchSetNum,
-          patchNum: 2 as RevisionPatchSetNum,
-        };
+        element.basePatchNum = 1 as BasePatchSetNum;
+        element.patchNum = 2 as RevisionPatchSetNum;
         await element.updateCleanlyMergedPaths();
         await element.updateComplete;
 
@@ -1756,10 +1776,8 @@
       });
 
       test('not shown in edit mode', async () => {
-        element.patchRange = {
-          basePatchNum: 1 as BasePatchSetNum,
-          patchNum: EDIT,
-        };
+        element.basePatchNum = 1 as BasePatchSetNum;
+        element.patchNum = EDIT;
         await element.updateCleanlyMergedPaths();
         await element.updateComplete;
 
@@ -1776,10 +1794,8 @@
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       const path = 'index.php';
       element.editMode = false;
       assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
@@ -1791,10 +1807,8 @@
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.editMode = false;
       const path = '/COMMIT_MSG';
       assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
@@ -1806,10 +1820,8 @@
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.editMode = true;
       const path = 'index.php';
       assert.equal(
@@ -1824,10 +1836,8 @@
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.editMode = true;
       const path = '/COMMIT_MSG';
       assert.equal(
@@ -2113,10 +2123,8 @@
       ];
       element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 2 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 2 as RevisionPatchSetNum;
       sinon
         .stub(window, 'fetch')
         .callsFake(() => Promise.resolve(new Response()));
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index fcfe209..33dfe82 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -12,6 +12,7 @@
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
 
 interface DisplayGroup {
   title: string;
@@ -197,12 +198,7 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'close', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 50c5caf..c669209 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -18,21 +18,20 @@
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {fire} from '../../../utils/event-util';
+import {LabelsChangedDetail} from '../../../api/change-reply';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-label-score-row': GrLabelScoreRow;
   }
+  interface HTMLElementEventMap {
+    'labels-changed': CustomEvent<LabelsChangedDetail>;
+  }
 }
 
 @customElement('gr-label-score-row')
 export class GrLabelScoreRow extends LitElement {
-  /**
-   * Fired when any label is changed.
-   *
-   * @event labels-changed
-   */
-
   @query('#labelSelector')
   labelSelector?: IronSelectorElement;
 
@@ -365,13 +364,7 @@
     this.selectedValueText = selectedItem.getAttribute('title') || '';
     const name = selectedItem.dataset['name'];
     const value = selectedItem.dataset['value'];
-    this.dispatchEvent(
-      new CustomEvent('labels-changed', {
-        detail: {name, value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    if (name && value) fire(this, 'labels-changed', {name, value});
   };
 
   private computePermittedLabelValues() {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 873e6ce..3a83fa1 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -5,7 +5,7 @@
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {
   ChangeInfo,
@@ -107,8 +107,7 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-4">Trigger Votes</h3>
-        <div class="permissionMessage">You don't have permission to vote</div>`;
+      return nothing;
     }
     return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
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 a4da747..72969a7 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -48,6 +48,11 @@
 import {FormattedReviewerUpdateInfo} from '../../../types/types';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {fire} from '../../../utils/event-util';
+import {
+  ChangeMessageDeletedEventDetail,
+  ReplyEvent,
+} from '../../../types/events';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -55,6 +60,12 @@
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
   }
+  interface HTMLElementEventMap {
+    'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
+    'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
+    // prettier-ignore
+    'reply': ReplyEvent;
+  }
 }
 
 export interface MessageAnchorTapDetail {
@@ -70,12 +81,6 @@
    */
 
   /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
    * Fired when a change message is deleted.
    *
    * @event change-message-deleted
@@ -751,24 +756,13 @@
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
-    this.dispatchEvent(
-      new CustomEvent('message-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
+    fire(this, 'message-anchor-tap', detail);
   }
 
   private handleReplyTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('reply', {
-        detail: {message: this.message},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    // TODO: Fix the type casting. Might actually be a bug.
+    fire(this, 'reply', {message: this.message as ChangeMessage});
   }
 
   private handleDeleteMessage(e: Event) {
@@ -779,13 +773,10 @@
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this.isDeletingChangeMsg = false;
-        this.dispatchEvent(
-          new CustomEvent('change-message-deleted', {
-            detail: {message: this.message},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        // TODO: Fix the type casting. Might actually be a bug.
+        fire(this, 'change-message-deleted', {
+          message: this.message as ChangeMessage,
+        });
       });
   }
 
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 f3c23aa..a2ffcb8 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
@@ -92,7 +92,10 @@
 import {pluralize} from '../../../utils/string-util';
 import {
   fireAlert,
-  fireEvent,
+  fireError,
+  fire,
+  fireNoBubble,
+  fireNoBubbleNoCompose,
   fireIronAnnounce,
   fireReload,
   fireServerError,
@@ -116,7 +119,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
 import {classMap} from 'lit/directives/class-map.js';
-import {ValueChangedEvent} from '../../../types/events';
+import {
+  AddReviewerEvent,
+  RemoveReviewerEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {customElement, property, state, query} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
@@ -168,43 +175,6 @@
 
 @customElement('gr-reply-dialog')
 export class GrReplyDialog extends LitElement {
-  /**
-   * Fired when a reply is successfully sent.
-   *
-   * @event send
-   */
-
-  /**
-   * Fired when the user presses the cancel button.
-   *
-   * @event cancel
-   */
-
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when the reply dialog believes that the server side diff drafts
-   * have been updated and need to be refreshed.
-   *
-   * @event comment-refresh
-   */
-
-  /**
-   * Fires when the state of the send button (enabled/disabled) changes.
-   *
-   * @event send-disabled-changed
-   */
-
-  /**
-   * Fired to reload the change page.
-   *
-   * @event reload
-   */
-
   FocusTarget = FocusTarget;
 
   private readonly reporting = getAppContext().reportingService;
@@ -723,17 +693,19 @@
     // Plugins on reply-reviewers endpoint can take advantage of these
     // events to add / remove reviewers
 
-    this.addEventListener('add-reviewer', e => {
+    this.addEventListener('add-reviewer', (e: AddReviewerEvent) => {
+      const reviewer = e.detail.reviewer;
       // 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,
+        account: reviewer,
         count: 1,
       });
     });
 
-    this.addEventListener('remove-reviewer', e => {
-      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
+    this.addEventListener('remove-reviewer', (e: RemoveReviewerEvent) => {
+      const reviewer = e.detail.reviewer;
+      this.reviewersList?.removeAccount(reviewer);
     });
   }
 
@@ -1286,7 +1258,7 @@
     if (this.restApiService.hasPendingDiffDrafts()) {
       this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
-        fireEvent(this, 'comment-refresh');
+        fire(this, 'comment-refresh', {});
         this.savingComments = false;
       });
     }
@@ -1476,12 +1448,7 @@
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
-        this.dispatchEvent(
-          new CustomEvent('send', {
-            composed: true,
-            bubbles: false,
-          })
-        );
+        fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
         return;
       })
@@ -1595,7 +1562,7 @@
   onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
   computeAttentionButtonTitle(sendDisabled?: boolean) {
@@ -1864,12 +1831,7 @@
   async cancel() {
     assertIsDefined(this.change, 'change');
     if (!this.change?.owner) throw new Error('missing required owner property');
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
     await this.patchsetLevelGrComment?.save();
     this.rebuildReviewerArrays();
   }
@@ -1900,13 +1862,7 @@
       return;
     }
     return this.send(this.includeComments, this.canBeStarted).catch(err => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          bubbles: true,
-          composed: true,
-          detail: {message: `Error submitting review ${err}`},
-        })
-      );
+      fireError(this, `Error submitting review ${err}`);
     });
   }
 
@@ -2083,7 +2039,7 @@
   }
 
   sendDisabledChanged() {
-    this.dispatchEvent(new CustomEvent('send-disabled-changed'));
+    fireNoBubbleNoCompose(this, 'send-disabled-changed', {});
   }
 
   getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
@@ -2139,4 +2095,19 @@
   interface HTMLElementTagNameMap {
     'gr-reply-dialog': GrReplyDialog;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the user presses the cancel button. */
+    // prettier-ignore
+    'cancel': CustomEvent<{}>;
+    /**
+     * Fires when the reply dialog believes that the server side diff drafts
+     * have been updated and need to be refreshed.
+     */
+    'comment-refresh': CustomEvent<{}>;
+    /** Fired when a reply is successfully sent. */
+    // prettier-ignore
+    'send': CustomEvent<{}>;
+    /** Fires when the state of the send button (enabled/disabled) changes. */
+    'send-disabled-changed': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index f7b3aec..5bf6464 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -1886,7 +1886,7 @@
 
     // Remove and add to other field.
     reviewers.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: reviewer1},
         composed: true,
         bubbles: true,
@@ -1903,14 +1903,14 @@
       })
     );
     ccs.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: cc1},
         composed: true,
         bubbles: true,
       })
     );
     ccs.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: cc3},
         composed: true,
         bubbles: true,
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 9408b82..db19329 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,15 +23,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
 import {nothing} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ShowReplyDialogEvent} from '../../../types/events';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends LitElement {
-  /**
-   * Fired when the "Add reviewer..." button is tapped.
-   *
-   * @event show-reply-dialog
-   */
-
   @property({type: Object}) change?: ChangeInfo;
 
   @property({type: Object}) account?: AccountDetailInfo;
@@ -203,22 +199,10 @@
   handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
-      reviewersOnly: false,
-      ccsOnly: false,
+      reviewersOnly: this.reviewersOnly,
+      ccsOnly: this.ccsOnly,
     };
-    if (this.reviewersOnly) {
-      value.reviewersOnly = true;
-    }
-    if (this.ccsOnly) {
-      value.ccsOnly = true;
-    }
-    this.dispatchEvent(
-      new CustomEvent('show-reply-dialog', {
-        detail: {value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'show-reply-dialog', {value});
   }
 }
 
@@ -226,4 +210,8 @@
   interface HTMLElementTagNameMap {
     'gr-reviewer-list': GrReviewerList;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the "Add reviewer..." button is tapped. */
+    'show-reply-dialog': ShowReplyDialogEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 66ad38c..2766114 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -10,13 +10,12 @@
 import {
   AccountInfo,
   ChangeStatus,
-  isDetailedLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
 import {
-  canVote,
+  canReviewerVote,
   extractAssociatedLabels,
   getApprovalInfo,
   hasVotes,
@@ -204,7 +203,7 @@
       if (requirementLabels.includes(label)) {
         const labelInfo = allLabels[label];
         const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some(
-          reviewer => canVote(labelInfo, reviewer)
+          reviewer => canReviewerVote(labelInfo, reviewer)
         );
         if (hasVotes(labelInfo) || canSomeoneVote) {
           labels.push(label);
@@ -280,15 +279,17 @@
     labelName: string,
     type: 'override' | 'submittability'
   ) {
+    if (!this.account) return;
+    const votes = this.change?.permitted_labels?.[labelName];
+    if (!votes || votes.length < 1) return;
+    const maxVote = Number(votes[votes.length - 1]);
+    if (maxVote <= 0) return;
+
     const labels = this.change?.labels ?? {};
     const labelInfo = labels[labelName];
-    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
-    if (!this.account || !canVote(labelInfo, this.account)) return;
-
     const approvalInfo = getApprovalInfo(labelInfo, this.account);
-    const maxVote = approvalInfo?.permitted_voting_range?.max;
-    if (!maxVote || maxVote <= 0) return;
     if (approvalInfo?.value === maxVote) return; // Already voted maxVote
+
     return html` <div class="button quickApprove">
       <gr-button
         link=""
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 4e78e8a..18f8e4c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -253,6 +253,9 @@
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
       status: ChangeStatus.NEW,
+      permitted_labels: {
+        Verified: ['-1', ' 0', '+1', '+2'],
+      },
       labels: {
         Verified: {
           ...createDetailedLabelInfo(),
@@ -351,6 +354,9 @@
       const change: ParsedChangeInfo = {
         ...createParsedChange(),
         status: ChangeStatus.NEW,
+        permitted_labels: {
+          'Build-Cop': ['-1', ' 0', '+1', '+2'],
+        },
         labels: {
           'Build-Cop': {
             ...createDetailedLabelInfo(),
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 c09d2a2..599e38b 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
@@ -28,7 +28,11 @@
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {CommentTabState, TabState} from '../../../types/events';
+import {
+  CommentTabState,
+  TabState,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {css, html, LitElement, PropertyValues} from 'lit';
@@ -365,7 +369,7 @@
         <gr-dropdown-list
           id="sortDropdown"
           .value=${this.sortDropdownValue}
-          @value-change=${(e: CustomEvent) =>
+          @value-change=${(e: ValueChangedEvent<SortDropdownState>) =>
             (this.sortDropdownValue = e.detail.value)}
           .items=${this.getSortDropdownEntries()}
         >
@@ -521,7 +525,7 @@
   }
 
   // private, but visible for testing
-  handleCommentsDropdownValueChange(e: CustomEvent) {
+  handleCommentsDropdownValueChange(e: ValueChangedEvent<CommentTabState>) {
     const value = e.detail.value;
     switch (value) {
       case CommentTabState.UNRESOLVED:
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 5792230..f3be5c4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -329,9 +329,9 @@
   private computeIsExpandable() {
     const hasSummary = !!this.result?.summary;
     const hasMessage = !!this.result?.message;
-    const hasLinks = (this.result?.links ?? []).length > 0;
+    const hasMultipleLinks = (this.result?.links ?? []).length > 1;
     const hasPointers = (this.result?.codePointers ?? []).length > 0;
-    return hasSummary && (hasMessage || hasLinks || hasPointers);
+    return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
   }
 
   override focus() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 113470c..385bde7 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -117,6 +117,7 @@
           aria-checked="false"
           aria-label="Expand result row"
           class="show-hide"
+          hidden
           role="switch"
           tabindex="0"
         >
@@ -261,7 +262,6 @@
             </h3>
             <gr-result-row
               class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
-              isexpandable
             >
             </gr-result-row>
             <gr-result-row
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 128a9b0a..8ba9895 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -634,7 +634,7 @@
     return Object.entries(this.errorMessages).map(([plugin, message]) => {
       const msg = this.collapsed
         ? 'Error'
-        : `Error while fetching results for ${plugin}:<br />${message}`;
+        : html`Error while fetching results for ${plugin}:<br />${message}`;
       return html`
         <div class="error">
           <div class="left">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4bd3446..a858e4d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -22,6 +22,7 @@
     );
     const getChecksModel = resolve(element, checksModelToken);
     setAllFakeRuns(getChecksModel());
+    element.errorMessages = {'test-plugin-name': 'test-error-message'};
     await element.updateComplete;
   });
 
@@ -57,6 +58,17 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">
+              Error while fetching results for test-plugin-name: <br />
+              test-error-message
+            </div>
+          </div>
+        </div>
         <input
           id="filterInput"
           placeholder="Filter runs by regular expression"
@@ -121,6 +133,14 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">Error</div>
+          </div>
+        </div>
         <input
           hidden
           id="filterInput"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index c7477c4..f1a3fb9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -9,6 +9,7 @@
   AttemptChoice,
   LATEST_ATTEMPT,
 } from '../../models/checks/checks-util';
+import {fire} from '../../utils/event-util';
 
 export interface RunSelectedEventDetail {
   checkName?: string;
@@ -23,13 +24,7 @@
 }
 
 export function fireRunSelected(target: EventTarget, checkName: string) {
-  target.dispatchEvent(
-    new CustomEvent('run-selected', {
-      detail: {reset: false, checkName},
-      composed: true,
-      bubbles: true,
-    })
-  );
+  fire(target, 'run-selected', {checkName});
 }
 
 export function isAttemptSelected(
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index b46d2b9..98dd574 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -8,7 +8,7 @@
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {
   DropdownContent,
   DropdownLink,
@@ -23,6 +23,9 @@
   interface HTMLElementTagNameMap {
     'gr-account-dropdown': GrAccountDropdown;
   }
+  interface HTMLElementEventMap {
+    'show-keyboard-shortcuts': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-account-dropdown')
@@ -136,7 +139,7 @@
   }
 
   _handleShortcutsTap() {
-    fireEvent(this, 'show-keyboard-shortcuts');
+    fire(this, 'show-keyboard-shortcuts', {});
   }
 
   private readonly handleLocationChange = () => {
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index 461781e..5510aa3 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -7,11 +7,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-error-dialog': GrErrorDialog;
   }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'dismiss': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-error-dialog')
@@ -83,6 +88,6 @@
   }
 
   private handleConfirm() {
-    this.dispatchEvent(new CustomEvent('dismiss'));
+    fireNoBubbleNoCompose(this, 'dismiss', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 1176ce3..e8675bc 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -14,7 +14,6 @@
 import {AccountId} from '../../../types/common';
 import {
   AuthErrorEvent,
-  EventType,
   NetworkErrorEvent,
   ServerErrorEvent,
   ShowAlertEventDetail,
@@ -124,9 +123,9 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
-    document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
-    document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.addEventListener('server-error', this.handleServerError);
+    document.addEventListener('network-error', this.handleNetworkError);
+    document.addEventListener('show-alert', this.handleShowAlert);
     document.addEventListener('hide-alert', this.hideAlert);
     document.addEventListener('show-error', this.handleShowErrorDialog);
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
@@ -140,15 +139,9 @@
 
   override disconnectedCallback() {
     this.clearHideAlertHandle();
-    document.removeEventListener(
-      EventType.SERVER_ERROR,
-      this.handleServerError
-    );
-    document.removeEventListener(
-      EventType.NETWORK_ERROR,
-      this.handleNetworkError
-    );
-    document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.removeEventListener('server-error', this.handleServerError);
+    document.removeEventListener('network-error', this.handleNetworkError);
+    document.removeEventListener('show-alert', this.handleShowAlert);
     document.removeEventListener('hide-alert', this.hideAlert);
     document.removeEventListener('show-error', this.handleShowErrorDialog);
     document.removeEventListener(
@@ -358,7 +351,7 @@
     el.show(text, actionText, actionCallback);
     this.alertElement = el;
     fireIronAnnounce(this, `Alert: ${text}`);
-    this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
+    this.reporting.reportInteraction('show-alert', {text});
   }
 
   private readonly hideAlert = () => {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index f4ee5ed..fff69ef 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -24,7 +24,6 @@
 import {waitUntil} from '../../../test/test-utils';
 import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
 import {authServiceToken} from '../../../services/gr-auth/gr-auth';
 
@@ -391,7 +390,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -439,7 +438,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -452,7 +451,7 @@
 
       // new alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'second-test', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -498,7 +497,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {
             message: 'test-alert',
             action: 'reload',
@@ -519,7 +518,7 @@
       const alertObj = {message: 'foo'};
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: alertObj,
           composed: true,
           bubbles: true,
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 45ba33b..a359072 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -16,6 +16,7 @@
   ShortcutViewListener,
 } from '../../../services/shortcuts/shortcuts-service';
 import {resolve} from '../../../models/dependency';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -162,12 +163,7 @@
   private handleCloseTap(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'close', {});
   }
 
   onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 833a91a..beb843a 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -25,7 +25,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {userModelToken} from '../../../models/user/user-model';
@@ -97,6 +97,9 @@
   interface HTMLElementTagNameMap {
     'gr-main-header': GrMainHeader;
   }
+  interface HTMLElementEventMap {
+    'mobile-search': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-main-header')
@@ -641,6 +644,6 @@
   private onMobileSearchTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'mobile-search');
+    fire(this, 'mobile-search', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index fe90471..1b377e8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -26,7 +26,7 @@
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
 import {GerritView, RouterModel} from '../../../services/router/router-model';
-import {fireAlert, firePageError} from '../../../utils/event-util';
+import {fire, fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
   encodeURL,
@@ -555,13 +555,7 @@
       hash: window.location.hash,
       pathname: window.location.pathname,
     };
-    document.dispatchEvent(
-      new CustomEvent('location-change', {
-        detail,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(document, 'location-change', detail);
   }
 
   _testOnly_startRouter() {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 314e126..406755f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -7,6 +7,7 @@
 import '../../shared/gr-icon/gr-icon';
 import {ServerInfo} from '../../../types/common';
 import {
+  AutocompleteCommitEvent,
   AutocompleteQuery,
   AutocompleteSuggestion,
   GrAutocomplete,
@@ -25,6 +26,8 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {ValueChangedEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -227,10 +230,10 @@
           .threshold=${this.threshold}
           tab-complete
           .verticalOffset=${30}
-          @commit=${(e: Event) => {
+          @commit=${(e: AutocompleteCommitEvent) => {
             this.handleInputCommit(e);
           }}
-          @text-changed=${(e: CustomEvent) => {
+          @text-changed=${(e: ValueChangedEvent) => {
             this.handleSearchTextChanged(e);
           }}
         >
@@ -285,7 +288,7 @@
     return `${baseUrl}/user-search.html`;
   }
 
-  private handleInputCommit(e: Event) {
+  private handleInputCommit(e: AutocompleteCommitEvent) {
     this.preventDefaultAndNavigateToInputVal(e);
   }
 
@@ -295,7 +298,7 @@
    * - e.target is the gr-autocomplete widget (#searchInput)
    * - e.target is the input element wrapped within #searchInput
    */
-  private preventDefaultAndNavigateToInputVal(e: Event) {
+  private preventDefaultAndNavigateToInputVal(e: AutocompleteCommitEvent) {
     e.preventDefault();
     if (!this.inputVal) return;
     const trimmedInput = this.inputVal.trim();
@@ -309,11 +312,7 @@
       const detail: SearchBarHandleSearchDetail = {
         inputVal: this.inputVal,
       };
-      this.dispatchEvent(
-        new CustomEvent('handle-search', {
-          detail,
-        })
-      );
+      fireNoBubbleNoCompose(this, 'handle-search', detail);
     }
   }
 
@@ -425,7 +424,7 @@
     this.searchInput.selectAll();
   }
 
-  private handleSearchTextChanged(e: CustomEvent) {
+  private handleSearchTextChanged(e: ValueChangedEvent) {
     this.inputVal = e.detail.value;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 1608c22..9d29cea 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -36,7 +36,6 @@
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
-import {changeModelToken} from '../../../models/change/change-model';
 import {fireReload} from '../../../utils/event-util';
 
 interface FilePreview {
@@ -92,9 +91,6 @@
   diffPrefs?: DiffPreferencesInfo;
 
   @state()
-  isOwner = false;
-
-  @state()
   onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[] = [];
 
   private readonly restApiService = getAppContext().restApiService;
@@ -103,8 +99,6 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
-  private readonly getChangeModel = resolve(this, changeModelToken);
-
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -114,11 +108,6 @@
     super();
     subscribe(
       this,
-      () => this.getChangeModel().isOwner$,
-      x => (this.isOwner = x)
-    );
-    subscribe(
-      this,
       () => this.getUserModel().preferences$,
       preferences => {
         const layers: DiffLayer[] = [this.syntaxLayer];
@@ -341,7 +330,6 @@
 
   private computeTooltip() {
     if (!this.change || !this.patchNum) return '';
-    if (!this.isOwner) return 'Fix can only be applied by author';
     const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
     return latestPatchNum !== this.patchNum
@@ -351,7 +339,6 @@
 
   private computeDisableApplyFixButton() {
     if (!this.change || !this.patchNum) return true;
-    if (!this.isOwner) return true;
     const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
     return this.patchNum !== latestPatchNum || this.isApplyFixLoading;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 9284fb2..b317b97 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -17,7 +17,7 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {EventType, OpenFixPreviewEventDetail} from '../../../types/events';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStub} from 'sinon';
@@ -51,7 +51,7 @@
 
   async function open(detail: OpenFixPreviewEventDetail) {
     element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+      new CustomEvent<OpenFixPreviewEventDetail>('open-fix-preview', {
         detail,
       })
     );
@@ -71,7 +71,6 @@
     element.changeNum = change._number;
     element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
-    element.isOwner = true;
     element.diffPrefs = {
       ...createDefaultDiffPrefs(),
       font_size: 12,
@@ -161,22 +160,8 @@
       assert.equal(button.getAttribute('title'), '');
     });
 
-    test('apply fix button is disabled for non-author', async () => {
-      element.isOwner = false;
-      await element.updateComplete;
-      await open(TWO_FIXES);
-      assert.equal(element.currentFix!.fix_id, 'fix_1');
-      assert.equal(element.currentPreviews.length, 2);
-      const button = getConfirmButton();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.equal(
-        button.getAttribute('title'),
-        'Fix can only be applied by author'
-      );
-    });
-
     test('apply fix button is disabled on older patchset', async () => {
-      element.change = {
+      element.change = element.change = {
         ...createParsedChange(),
         revisions: createRevisions(2),
         current_revision: getCurrentRevision(0),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index fd0e5d2..43a6819 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -58,9 +58,8 @@
   firePageError,
   fireAlert,
   fireServerError,
-  fireEvent,
-  waitForEventOnce,
   fire,
+  waitForEventOnce,
 } from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
@@ -68,7 +67,11 @@
 import {Timing, Interaction} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
-import {DisplayLine, RenderPreferences} from '../../../api/diff';
+import {
+  DisplayLine,
+  LineSelectedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -119,8 +122,8 @@
 
 declare global {
   interface HTMLElementEventMap {
-    /* prettier-ignore */
-    'render': CustomEvent;
+    // prettier-ignore
+    'render': CustomEvent<{}>;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent<CreateCommentEventDetail>;
     'is-blame-loaded-changed': ValueChangedEvent<boolean>;
@@ -129,9 +132,9 @@
     'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>;
     'is-image-diff-changed': ValueChangedEvent<boolean>;
     // Fired when the user selects a line (See gr-diff).
-    'line-selected': CustomEvent;
+    'line-selected': CustomEvent<LineSelectedEventDetail>;
     // Fired if being logged in is required.
-    'show-auth-required': void;
+    'show-auth-required': CustomEvent<{}>;
   }
 }
 
@@ -1238,7 +1241,7 @@
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return false;
     }
     if (!this.patchRange) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 7a32c27..141a24d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -51,7 +51,6 @@
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
 import {userModelToken, UserModel} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -662,7 +661,7 @@
     test('loadBlame', async () => {
       const mockBlame: BlameInfo[] = [createBlame()];
       const showAlertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('show-alert', showAlertStub);
       const getBlameStub = stubRestApi('getBlame').returns(
         Promise.resolve(mockBlame)
       );
@@ -694,7 +693,7 @@
       const mockBlame: BlameInfo[] = [];
       const showAlertStub = sinon.stub();
       const isBlameLoadedStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('show-alert', showAlertStub);
       element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
       stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
       const changeNum = 42 as NumericChangeId;
@@ -1328,7 +1327,7 @@
 
     test('cannot create thread on an edit', () => {
       const alertSpy = sinon.spy();
-      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+      element.addEventListener('show-alert', alertSpy);
 
       const diffSide = Side.RIGHT;
       element.patchRange = {
@@ -1356,7 +1355,7 @@
 
     test('cannot create thread on an edit base', () => {
       const alertSpy = sinon.spy();
-      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+      element.addEventListener('show-alert', alertSpy);
 
       const diffSide = Side.LEFT;
       element.patchRange = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 531d2ae..d580127 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -12,6 +12,7 @@
 import {customElement, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubble} from '../../../utils/event-util';
 
 @customElement('gr-diff-preferences-dialog')
 export class GrDiffPreferencesDialog extends LitElement {
@@ -120,12 +121,7 @@
     assertIsDefined(this.diffPreferences, 'diffPreferences');
     assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
     await this.diffPreferences.save();
-    this.dispatchEvent(
-      new CustomEvent('reload-diff-preference', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'reload-diff-preference', {});
     this.diffPrefsModal.close();
   }
 
@@ -140,4 +136,7 @@
   interface HTMLElementTagNameMap {
     'gr-diff-preferences-dialog': GrDiffPreferencesDialog;
   }
+  interface HTMLElementEventMap {
+    'reload-diff-preference': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 19186ec..14eb505 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -33,6 +33,7 @@
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {CommentAnchorTapEventDetail} from '../../shared/gr-comment/gr-comment';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   BasePatchSetNum,
@@ -48,13 +49,16 @@
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {FileRange, ParsedChangeInfo} from '../../../types/types';
-import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {
+  FilesWebLinks,
+  PatchRangeChangeEvent,
+} from '../gr-patch-range-select/gr-patch-range-select';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {CommentMap} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fire, fireTitleChange} from '../../../utils/event-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {toggleClass, whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
@@ -66,7 +70,7 @@
   ShortcutSection,
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
-import {DisplayLine} from '../../../api/diff';
+import {DisplayLine, LineSelectedEventDetail} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -768,7 +772,7 @@
         .path=${this.path}
         .projectName=${this.change?.project}
         @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-        @comment-anchor-tap=${this.onLineSelected}
+        @comment-anchor-tap=${this.onCommentAnchorTap}
         @line-selected=${this.onLineSelected}
         @diff-changed=${this.onDiffChanged}
         @edit-weblinks-changed=${this.onEditWeblinksChanged}
@@ -1193,7 +1197,7 @@
   // Similar to gr-change-view.handleOpenReplyDialog
   private handleOpenReplyDialog() {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return;
     }
     this.getChangeModel().navigateToChange(true);
@@ -1443,7 +1447,7 @@
   }
 
   // Private but used in tests.
-  handlePatchChange(e: CustomEvent) {
+  handlePatchChange(e: PatchRangeChangeEvent) {
     if (!this.path) return;
     if (!this.patchNum) return;
 
@@ -1466,16 +1470,23 @@
   }
 
   // Private but used in tests.
-  onLineSelected(e: CustomEvent) {
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be left/right
+  onCommentAnchorTap(e: CustomEvent<CommentAnchorTapEventDetail>) {
+    const lineNumber = e.detail.number;
+    if (!Number.isInteger(lineNumber)) return;
     this.updateUrlToDiffUrl(
-      e.detail.number,
-      e.detail.side === Side.LEFT || e.detail.side === CommentSide.PARENT
+      lineNumber as number,
+      e.detail.side === CommentSide.PARENT
     );
   }
 
   // Private but used in tests.
+  onLineSelected(e: CustomEvent<LineSelectedEventDetail>) {
+    const lineNumber = e.detail.number;
+    if (!Number.isInteger(lineNumber)) return;
+    this.updateUrlToDiffUrl(lineNumber as number, e.detail.side === Side.LEFT);
+  }
+
+  // Private but used in tests.
   computeDownloadDropdownLinks() {
     if (!this.change?.project) return [];
     if (!this.changeNum) return [];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 6507046..e7e1bc8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -60,7 +60,6 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -1698,10 +1697,7 @@
         pressKey(element, 'n');
 
         assert.isTrue(moveToNextChunkStub.called);
-        assert.equal(
-          dispatchEventStub.lastCall.args[0].type,
-          EventType.SHOW_ALERT
-        );
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
         assert.isFalse(navToFileStub.called);
       });
 
@@ -1741,10 +1737,7 @@
         pressKey(element, 'p');
 
         assert.isTrue(moveToPreviousChunkStub.called);
-        assert.equal(
-          dispatchEventStub.lastCall.args[0].type,
-          EventType.SHOW_ALERT
-        );
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
         assert.isFalse(navToFileStub.called);
       });
 
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 325798c..01c007b 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
@@ -47,6 +47,7 @@
 import {GeneratedWebLink} from '../../../utils/weblink-util';
 import {changeModelToken} from '../../../models/change/change-model';
 import {changeViewModelToken} from '../../../models/views/change';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -56,7 +57,7 @@
 }
 
 export interface PatchRangeChangeDetail {
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
 }
 
@@ -73,6 +74,12 @@
   }
 }
 
+declare global {
+  interface HTMLElementEventMap {
+    'patch-range-change': PatchRangeChangeEvent;
+  }
+}
+
 /**
  * Fired when the patch range changes
  *
@@ -463,7 +470,9 @@
       basePatchNum: this.basePatchNum,
     };
     const target = e.target;
-    const patchSetValue = convertToPatchSetNum(e.detail.value)!;
+    const patchSetValue = convertToPatchSetNum(
+      e.detail.value
+    ) as RevisionPatchSetNum;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.patchNumDropdown) {
       if (detail.patchNum === patchSetValue) return;
@@ -488,8 +497,6 @@
       detail.basePatchNum = patchSetValue as BasePatchSetNum;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('patch-range-change', {detail, bubbles: false})
-    );
+    fireNoBubbleNoCompose(this, 'patch-range-change', detail);
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 584fcd7..3813946 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -469,10 +469,10 @@
     await element.updateComplete;
 
     const stub = stubReporting('reportInteraction');
-    fire(element.patchNumDropdown!, 'value-change', {value: '1'});
+    fire(element.patchNumDropdown, 'value-change', {value: '1'});
     assert.isFalse(stub.called);
 
-    fire(element.patchNumDropdown!, 'value-change', {value: '2'});
+    fire(element.patchNumDropdown, 'value-change', {value: '2'});
     assert.isTrue(stub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 7229c63..25d3cf4 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -6,11 +6,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-default-editor': GrDefaultEditor;
   }
+  interface HTMLElementEventMap {
+    'content-change': ValueChangedEvent;
+  }
 }
 
 @customElement('gr-default-editor')
@@ -56,12 +61,7 @@
   }
 
   _handleTextareaInput(e: Event) {
-    this.dispatchEvent(
-      new CustomEvent('content-change', {
-        detail: {value: (e.target as HTMLTextAreaElement).value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    const value = (e.target as HTMLTextAreaElement).value;
+    fire(this, 'content-change', {value});
   }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index c442aa6..751b64d 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -6,8 +6,11 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import {GrEditConstants} from '../gr-edit-constants';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {FileActionTapEvent} from '../../../types/events';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {fire} from '../../../utils/event-util';
 
 interface EditAction {
   label: string;
@@ -16,12 +19,6 @@
 
 @customElement('gr-edit-file-controls')
 export class GrEditFileControls extends LitElement {
-  /**
-   * Fired when an action in the overflow menu is tapped.
-   *
-   * @event file-action-tap
-   */
-
   @property({type: String})
   filePath?: string;
 
@@ -64,23 +61,20 @@
     >`;
   }
 
-  _handleActionTap(e: CustomEvent) {
+  _handleActionTap(e: CustomEvent<DropdownLink>) {
     e.preventDefault();
     e.stopPropagation();
-    this._dispatchFileAction(e.detail.id, this.filePath);
+    const actionId = e.detail.id;
+    if (!actionId) return;
+    if (!this.filePath) return;
+    this._dispatchFileAction(actionId, this.filePath);
   }
 
-  _dispatchFileAction(action: EditAction, path?: string) {
-    this.dispatchEvent(
-      new CustomEvent('file-action-tap', {
-        detail: {action, path},
-        bubbles: true,
-        composed: true,
-      })
-    );
+  _dispatchFileAction(action: string, path: string) {
+    fire(this, 'file-action-tap', {action, path});
   }
 
-  _computeFileActions(actions: EditAction[]) {
+  _computeFileActions(actions: EditAction[]): DropdownLink[] {
     // TODO(kaspern): conditionally disable some actions based on file status.
     return actions.map(action => {
       return {
@@ -95,4 +89,7 @@
   interface HTMLElementTagNameMap {
     'gr-edit-file-controls': GrEditFileControls;
   }
+  interface HTMLElementEventMap {
+    'file-action-tap': FileActionTapEvent;
+  }
 }
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 acc4c9e..12b19c7 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
@@ -13,7 +13,7 @@
 import {
   EditPreferencesInfo,
   Base64FileContent,
-  PatchSetNumber,
+  RevisionPatchSetNum,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
@@ -26,7 +26,7 @@
 import {Modifier} from '../../../utils/dom-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -63,8 +63,7 @@
    * @event show-alert
    */
 
-  @property({type: Object})
-  viewState?: ChangeViewState;
+  @state() viewState?: ChangeViewState;
 
   // private but used in test
   @state() change?: ParsedChangeInfo;
@@ -87,7 +86,7 @@
   @state() private editPrefs?: EditPreferencesInfo;
 
   // private but used in test
-  @state() latestPatchsetNumber?: PatchSetNumber;
+  @state() latestPatchsetNumber?: RevisionPatchSetNum;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -130,7 +129,7 @@
     );
     subscribe(
       this,
-      () => this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNumWithEdit$,
       x => (this.latestPatchsetNumber = x)
     );
     this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 1a4879d..0a36a05 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -17,7 +17,6 @@
 import {
   EDIT,
   NumericChangeId,
-  PatchSetNumber,
   RevisionPatchSetNum,
 } from '../../../types/common';
 import {
@@ -28,7 +27,6 @@
 import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
@@ -51,9 +49,9 @@
     navigateStub = sinon.stub(element, 'viewEditInChangeView');
     element.viewState = {
       ...createEditViewState(),
-      patchNum: 1 as PatchSetNumber,
+      patchNum: 1 as RevisionPatchSetNum,
     };
-    element.latestPatchsetNumber = 1 as PatchSetNumber;
+    element.latestPatchsetNumber = 1 as RevisionPatchSetNum;
     await element.updateComplete;
     storageService = testResolver(storageServiceToken);
   });
@@ -446,7 +444,7 @@
 
   test('showAlert', async () => {
     const promise = mockPromise();
-    element.addEventListener(EventType.SHOW_ALERT, e => {
+    element.addEventListener('show-alert', e => {
       assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
       assert.isTrue(e.bubbles);
       promise.resolve();
@@ -534,7 +532,7 @@
       };
 
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       return element.getFileData().then(async () => {
         await element.updateComplete;
@@ -566,7 +564,7 @@
       };
 
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       return element.getFileData().then(async () => {
         await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index e5991f6..98f1f25 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -47,7 +47,6 @@
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
   DialogChangeEventDetail,
-  EventType,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
@@ -175,19 +174,19 @@
   constructor() {
     super();
 
-    document.addEventListener(EventType.PAGE_ERROR, e => {
+    document.addEventListener('page-error', e => {
       this.handlePageError(e);
     });
-    this.addEventListener(EventType.TITLE_CHANGE, e => {
+    this.addEventListener('title-change', e => {
       this.handleTitleChange(e);
     });
-    this.addEventListener(EventType.DIALOG_CHANGE, e => {
+    this.addEventListener('dialog-change', e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    document.addEventListener(EventType.LOCATION_CHANGE, () =>
+    document.addEventListener('location-change', () =>
       this.handleLocationChange()
     );
-    document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
+    document.addEventListener('gr-rpc-log', e => this.handleRpcLog(e));
     this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
       this.showKeyboardShortcuts()
     );
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index bc4e701..b34e1c3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -6,6 +6,7 @@
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ValueChangedEvent} from '../../../types/events';
 
 export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -51,7 +52,7 @@
   bind(name: string, callback: (value: any) => void) {
     this.reporting.trackApi(this.plugin, 'attribute', 'bind');
     const attributeChangedEventName = this._getChangedEventName(name);
-    const changedHandler = (e: CustomEvent) =>
+    const changedHandler = (e: ValueChangedEvent) =>
       this._reportValue(callback, e.detail.value);
     const unbind = () =>
       this.element.removeEventListener(
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index e73aad6..d3429fe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -5,6 +5,7 @@
  */
 import {LitElement, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -22,9 +23,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('value')) {
-      this.dispatchEvent(
-        new CustomEvent('value-changed', {detail: {value: this.value}})
-      );
+      fireNoBubbleNoCompose(this, 'value-changed', {value: this.value});
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 0e75abb..89513e3 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -15,7 +15,7 @@
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -25,12 +25,6 @@
 
 @customElement('gr-account-info')
 export class GrAccountInfo extends LitElement {
-  /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
   // private but used in test
   @state() nameMutable?: boolean;
 
@@ -341,7 +335,7 @@
         this.hasDisplayNameChange = false;
         this.hasStatusChange = false;
         this.saving = false;
-        fireEvent(this, 'account-detail-update');
+        fire(this, 'account-detail-update', {});
       });
   }
 
@@ -410,6 +404,8 @@
 declare global {
   interface HTMLElementEventMap {
     'unsaved-changes-changed': ValueChangedEvent<boolean>;
+    /** Fired when account details are changed. */
+    'account-detail-update': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-account-info': GrAccountInfo;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index a20c0ee..c6c023e 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -9,7 +9,7 @@
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -27,12 +27,6 @@
 @customElement('gr-registration-dialog')
 export class GrRegistrationDialog extends LitElement {
   /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
-  /**
    * Fired when the close button is pressed.
    *
    * @event close
@@ -293,7 +287,7 @@
 
     return Promise.all(promises).then(() => {
       this.saving = false;
-      fireEvent(this, 'account-detail-update');
+      fire(this, 'account-detail-update', {});
     });
   }
 
@@ -309,7 +303,7 @@
 
   private close() {
     this.saving = true; // disable buttons indefinitely
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index a5ef86d..a0d7fab 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -38,7 +38,6 @@
 } from '../../../test/test-data-generators';
 import {GrSelect} from '../../shared/gr-select/gr-select';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 
 suite('gr-settings-view tests', () => {
   let element: GrSettingsView;
@@ -915,7 +914,7 @@
       await element._testOnly_loadingPromise;
       assert.equal(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
-        EventType.SHOW_ALERT
+        'show-alert'
       );
       assert.deepEqual(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 31e4f5d..d7c6c8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,6 +17,8 @@
 import {customElement, property} from 'lit/decorators.js';
 import {ClassInfo, classMap} from 'lit/directives/class-map.js';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
+import {fire} from '../../../utils/event-util';
+import {RemoveAccountEvent} from '../../../types/events';
 
 @customElement('gr-account-chip')
 export class GrAccountChip extends LitElement {
@@ -196,13 +198,8 @@
 
   private handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        detail: {account: this.account},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (!this.account) return;
+    fire(this, 'remove-account', {account: this.account});
   }
 
   private getHasAvatars() {
@@ -232,4 +229,7 @@
   interface HTMLElementTagNameMap {
     'gr-account-chip': GrAccountChip;
   }
+  interface HTMLElementEventMap {
+    'remove-account': RemoveAccountEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 0509925..496dff1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -5,15 +5,17 @@
  */
 import '../gr-autocomplete/gr-autocomplete';
 import {
+  AutocompleteCommitEvent,
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {AddAccountEvent, BindValueChangeEvent} from '../../../types/events';
 import {SuggestedReviewerInfo} from '../../../types/common';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {fire} from '../../../utils/event-util';
 
 /**
  * gr-account-entry is an element for entering account
@@ -23,20 +25,6 @@
 export class GrAccountEntry extends LitElement {
   @query('#input') private input?: GrAutocomplete;
 
-  /**
-   * Fired when an account is entered.
-   *
-   * @event add
-   */
-
-  /**
-   * When allowAnyInput is true, account-text-changed is fired when input text
-   * changed. This is needed so that the reply dialog's save button can be
-   * enabled for arbitrary cc's, which don't need a 'commit'.
-   *
-   * @event account-text-changed
-   */
-
   @property({type: Boolean})
   allowAnyInput = false;
 
@@ -110,22 +98,14 @@
     return this.input!.text;
   }
 
-  private handleInputCommit(e: CustomEvent) {
-    this.dispatchEvent(
-      new CustomEvent('add', {
-        detail: {value: e.detail.value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private handleInputCommit(e: AutocompleteCommitEvent) {
+    fire(this, 'add', {value: e.detail.value});
     this.input!.focus();
   }
 
   private inputTextChanged() {
     if (this.inputText.length && this.allowAnyInput) {
-      this.dispatchEvent(
-        new CustomEvent('account-text-changed', {bubbles: true, composed: true})
-      );
+      fire(this, 'account-text-changed', {});
     }
   }
 
@@ -138,4 +118,15 @@
   interface HTMLElementTagNameMap {
     'gr-account-entry': GrAccountEntry;
   }
+  interface HTMLElementEventMap {
+    /** Fired when an account is entered. */
+    // prettier-ignore
+    'add': AddAccountEvent;
+    /**
+     * When allowAnyInput is true, account-text-changed is fired when input text
+     * changed. This is needed so that the reply dialog's save button can be
+     * enabled for arbitrary cc's, which don't need a 'commit'.
+     */
+    'account-text-changed': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index bb0200a..cf7ff2209 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -13,9 +13,8 @@
 import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {classMap} from 'lit/directives/class-map.js';
@@ -364,16 +363,10 @@
     e.stopPropagation();
     if (!this.account._account_id) return;
 
-    this.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -394,7 +387,7 @@
         reason
       )
       .then(() => {
-        fireEvent(this, 'hide-alert');
+        fire(this, 'hide-alert', {});
       });
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 65d8859..8339df9 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -38,18 +38,17 @@
 import {ReviewerState} from '../../../api/rest-api';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
+const VALID_USER_GROUP_ALERT = 'Please input a valid user or group.';
 
 declare global {
   interface HTMLElementEventMap {
     'accounts-changed': ValueChangedEvent<(AccountInfo | GroupInfo)[]>;
     'pending-confirmation-changed': ValueChangedEvent<SuggestedReviewerGroupInfo | null>;
+    'account-added': CustomEvent<{account: AccountInfo | GroupInfo}>;
   }
   interface HTMLElementTagNameMap {
     'gr-account-list': GrAccountList;
   }
-  interface HTMLElementEventMap {
-    'account-added': CustomEvent<AccountInputDetail>;
-  }
 }
 export interface AccountInputDetail {
   account: AccountInput;
@@ -153,7 +152,7 @@
   constructor() {
     super();
     this.querySuggestions = input => this.getSuggestions(input);
-    this.addEventListener('remove', e =>
+    this.addEventListener('remove-account', e =>
       this.handleRemove(e as CustomEvent<{account: AccountInput}>)
     );
   }
@@ -264,24 +263,28 @@
     this.addAccountItem(item);
   }
 
-  addAccountItem(item: RawAccountInput) {
+  /**
+   * Check if account or group is valid and add it.
+   *
+   * @return true if account or group is added.
+   */
+  addAccountItem(item: RawAccountInput): boolean {
     // Append new account or group to the accounts property. We add our own
     // internal properties to the account/group here, so we clone the object
     // to avoid cluttering up the shared change object.
-    let account;
-    let group;
+    let accountOrGroup: AccountInfo | GroupInfo | undefined;
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
-      account = {...item.account};
-      this.accounts.push(account);
+      accountOrGroup = {...item.account};
+      this.accounts.push(accountOrGroup);
       itemTypeAdded = 'account';
     } else if (isSuggestedReviewerGroupInfo(item)) {
       if (item.confirm) {
         this.pendingConfirmation = item;
-        return;
+        return false;
       }
-      group = {...item.group};
-      this.accounts.push(group);
+      accountOrGroup = {...item.group};
+      this.accounts.push(accountOrGroup);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -291,13 +294,17 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        account = {email: item as EmailAddress};
-        this.accounts.push(account);
+        accountOrGroup = {email: item as EmailAddress};
+        this.accounts.push(accountOrGroup);
         itemTypeAdded = 'email';
       }
     }
+    if (!accountOrGroup) {
+      fireAlert(this, VALID_USER_GROUP_ALERT);
+      return false;
+    }
     fire(this, 'accounts-changed', {value: this.accounts.slice()});
-    fire(this, 'account-added', {account: (account ?? group)! as AccountInput});
+    fire(this, 'account-added', {account: accountOrGroup});
     this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
     this.pendingConfirmation = null;
     this.requestUpdate();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index cc2723e..e7112df 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -35,7 +35,6 @@
 import {createChange} from '../../../test/test-data-generators';
 import {ReviewerState} from '../../../api/rest-api';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 
 class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
   init() {}
@@ -154,7 +153,7 @@
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: existingAccount1},
         composed: true,
         bubbles: true,
@@ -168,14 +167,14 @@
 
     // Invalid remove is ignored.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: existingAccount1},
         composed: true,
         bubbles: true,
       })
     );
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: newAccount},
         composed: true,
         bubbles: true,
@@ -197,7 +196,7 @@
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: newGroup},
         composed: true,
         bubbles: true,
@@ -289,6 +288,15 @@
     assert.isFalse(element.computeRemovable(newAccount));
   });
 
+  test('addAccountItem with invalid item', () => {
+    const toastHandler = sinon.stub();
+    element.allowAnyInput = false;
+    element.addEventListener('show-alert', toastHandler);
+    const result = element.addAccountItem('test');
+    assert.isFalse(result);
+    assert.isTrue(toastHandler.called);
+  });
+
   test('submitEntryText', async () => {
     element.allowAnyInput = true;
     await element.updateComplete;
@@ -425,7 +433,7 @@
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, toastHandler);
+      element.addEventListener('show-alert', toastHandler);
       handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 91e601c..4b27948 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -6,7 +6,7 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {Key} from '../../../utils/dom-util';
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
@@ -20,6 +20,9 @@
   interface HTMLElementTagNameMap {
     'gr-autocomplete-dropdown': GrAutocompleteDropdown;
   }
+  interface HTMLElementEventMap {
+    'dropdown-closed': CustomEvent<{}>;
+  }
 }
 
 export interface Item {
@@ -30,11 +33,21 @@
   value?: string;
 }
 
-export interface ItemSelectedEvent {
+export interface ItemSelectedEventDetail {
   trigger: string;
   selected: HTMLElement | null;
 }
 
+export enum AutocompleteQueryStatusType {
+  LOADING = 'loading',
+  ERROR = 'error',
+}
+
+export interface AutocompleteQueryStatus {
+  type: AutocompleteQueryStatusType;
+  message: string;
+}
+
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends LitElement {
   /**
@@ -58,8 +71,8 @@
   /** If specified a single non-interactable line is shown instead of
    * suggestions.
    */
-  @property({type: String})
-  errorMessage?: String;
+  @property({type: Object})
+  queryStatus?: AutocompleteQueryStatus;
 
   @property({type: Number})
   verticalOffset = 0;
@@ -117,10 +130,12 @@
         li.selected {
           background-color: var(--hover-background-color);
         }
-        li.query-error {
+        li.query-status {
           background-color: var(--disabled-background);
-          color: var(--error-foreground);
           cursor: default;
+        }
+        li.query-status.error {
+          color: var(--error-foreground);
           white-space: pre-wrap;
         }
         @media only screen and (max-height: 35em) {
@@ -140,7 +155,7 @@
   }
 
   private isSuggestionListInteractible() {
-    return !this.isHidden && !this.errorMessage;
+    return !this.isHidden && !this.queryStatus;
   }
 
   constructor() {
@@ -172,7 +187,8 @@
   override updated(changedProperties: PropertyValues) {
     if (
       changedProperties.has('suggestions') ||
-      changedProperties.has('isHidden')
+      changedProperties.has('isHidden') ||
+      changedProperties.has('queryStatus')
     ) {
       if (!this.isHidden) {
         this.computeCursorStopsAndRefit();
@@ -180,15 +196,19 @@
     }
   }
 
-  private renderError() {
+  private renderStatus() {
     return html`
       <li
         tabindex="-1"
-        aria-label="autocomplete query error"
-        class="query-error"
+        aria-label="autocomplete query status"
+        class="query-status ${this.queryStatus?.type}"
       >
-        <span>${this.errorMessage}</span>
-        <span class="label">ERROR</span>
+        <span>${this.queryStatus?.message}</span>
+        <span class="label"
+          >${this.queryStatus?.type === AutocompleteQueryStatusType.ERROR
+            ? 'ERROR'
+            : ''}</span
+        >
       </li>
     `;
   }
@@ -198,8 +218,8 @@
       <div class="dropdown-content" id="suggestions" role="listbox">
         <ul>
           ${when(
-            this.errorMessage,
-            () => this.renderError(),
+            this.queryStatus,
+            () => this.renderStatus(),
             () => html`
               ${repeat(
                 this.suggestions,
@@ -236,7 +256,7 @@
   }
 
   getCurrentText() {
-    if (!this.errorMessage) {
+    if (!this.queryStatus) {
       return this.getCursorTarget()?.dataset['value'] || '';
     }
     return '';
@@ -257,32 +277,20 @@
   // private but used in tests
   handleTab() {
     if (this.isSuggestionListInteractible()) {
-      this.dispatchEvent(
-        new CustomEvent<ItemSelectedEvent>('item-selected', {
-          detail: {
-            trigger: 'tab',
-            selected: this.cursor.target,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'item-selected', {
+        trigger: 'tab',
+        selected: this.cursor.target,
+      });
     }
   }
 
   // private but used in tests
   handleEnter() {
     if (this.isSuggestionListInteractible()) {
-      this.dispatchEvent(
-        new CustomEvent<ItemSelectedEvent>('item-selected', {
-          detail: {
-            trigger: 'enter',
-            selected: this.cursor.target,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'item-selected', {
+        trigger: 'enter',
+        selected: this.cursor.target,
+      });
     }
   }
 
@@ -301,20 +309,14 @@
       }
       selected = selected.parentElement!;
     }
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'click',
-          selected,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'item-selected', {
+      trigger: 'click',
+      selected,
+    });
   }
 
   private fireClose() {
-    fireEvent(this, 'dropdown-closed');
+    fire(this, 'dropdown-closed', {});
   }
 
   getCursorTarget() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 54d054b..10ba5d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -5,7 +5,10 @@
  */
 import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
-import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from './gr-autocomplete-dropdown';
 import {
   pressKey,
   queryAll,
@@ -177,7 +180,7 @@
     });
   });
 
-  suite('error tests', () => {
+  suite('status tests', () => {
     let element: GrAutocompleteDropdown;
 
     setup(async () => {
@@ -185,7 +188,10 @@
         html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
       );
       element.open();
-      element.errorMessage = 'Failed query error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Failed query error',
+      };
       await waitEventLoop();
     });
 
@@ -200,8 +206,8 @@
           <div class="dropdown-content" id="suggestions" role="listbox">
             <ul>
               <li
-                aria-label="autocomplete query error"
-                class="query-error"
+                aria-label="autocomplete query status"
+                class="query-status error"
                 tabindex="-1"
               >
                 <span>Failed query error</span>
@@ -213,6 +219,31 @@
       );
     });
 
+    test('renders loading', async () => {
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.LOADING,
+        message: 'Loading...',
+      };
+      await waitEventLoop();
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query status"
+                class="query-status loading"
+                tabindex="-1"
+              >
+                <span>Loading...</span>
+                <span class="label"></span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
+
     test('escape key close dropdown with error', async () => {
       const closeSpy = sinon.spy(element, 'close');
       pressKey(element, Key.ESC);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index f8f7f9d..a2be983 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -7,8 +7,13 @@
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {
+  AutocompleteQueryStatus,
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+  ItemSelectedEventDetail,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {fire} from '../../../utils/event-util';
 import {
   debounce,
   DelayedTask,
@@ -69,13 +74,6 @@
    */
 
   /**
-   * Fired on keydown to allow for custom hooks into autocomplete textbox
-   * behavior.
-   *
-   * @event input-keydown
-   */
-
-  /**
    * Query for requesting autocomplete suggestions. The function should
    * accept the input as a string parameter and return a promise. The
    * promise yields an array of suggestion objects with "name", "label",
@@ -166,7 +164,7 @@
 
   @state() suggestions: AutocompleteSuggestion[] = [];
 
-  @state() queryErrorMessage?: string;
+  @state() queryStatus?: AutocompleteQueryStatus;
 
   @state() index: number | null = null;
 
@@ -179,8 +177,23 @@
 
   @state() selected: HTMLElement | null = null;
 
+  /**
+   * The query id that status or suggestions correspond to.
+   */
+  private activeQueryId = 0;
+
+  /**
+   * Last scheduled update suggestions task.
+   */
   private updateSuggestionsTask?: DelayedTask;
 
+  // Generate ids for scheduled suggestion queries to easily distinguish them.
+  private static NEXT_QUERY_ID = 1;
+
+  private static getNextQueryId() {
+    return GrAutocomplete.NEXT_QUERY_ID++;
+  }
+
   /**
    * @return Promise that resolves when suggestions are update.
    */
@@ -266,7 +279,7 @@
     }
     if (
       changedProperties.has('suggestions') ||
-      changedProperties.has('queryErrorMessage')
+      changedProperties.has('queryStatus')
     ) {
       this.updateDropdownVisibility();
     }
@@ -286,7 +299,7 @@
         class=${this.computeClass()}
         ?disabled=${this.disabled}
         .value=${this.text}
-        @value-changed=${(e: CustomEvent) => {
+        @value-changed=${(e: ValueChangedEvent) => {
           this.text = e.detail.value;
         }}
         .placeholder=${this.placeholder}
@@ -310,7 +323,7 @@
         @item-selected=${this.handleItemSelect}
         @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
-        .errorMessage=${this.queryErrorMessage}
+        .queryStatus=${this.queryStatus}
         role="listbox"
         .index=${this.index}
       >
@@ -347,14 +360,16 @@
     this.text = '';
   }
 
-  private handleItemSelectEnter(e: CustomEvent | KeyboardEvent) {
+  private handleItemSelectEnter(
+    e: CustomEvent<ItemSelectedEventDetail> | KeyboardEvent
+  ) {
     this.handleInputCommit();
     e.stopPropagation();
     e.preventDefault();
     this.focusWithoutDisplayingSuggestions();
   }
 
-  handleItemSelect(e: CustomEvent) {
+  handleItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
     if (e.detail.trigger === 'click') {
       this.selected = e.detail.selected;
       this._commit();
@@ -412,8 +427,7 @@
     // Reset suggestions for every update
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
-    this.suggestions = [];
-    this.queryErrorMessage = undefined;
+    this.resetQueryOutput();
 
     // TODO(taoalpha): Also skip if text has not changed
 
@@ -421,8 +435,7 @@
       return;
     }
 
-    const query = this.query;
-    if (!query) {
+    if (!this.query) {
       return;
     }
 
@@ -435,50 +448,69 @@
       return;
     }
 
-    const requestText = this.text;
-    const update = () => {
-      query(this.text)
-        .then(suggestions => {
-          if (requestText !== this.text) {
-            // Late response.
-            return;
-          }
-          for (const suggestion of suggestions) {
-            suggestion.text = suggestion?.name ?? '';
-          }
-          this.suggestions = suggestions;
-          if (this.index === -1) {
-            this.value = '';
-          }
-        })
-        .catch(e => {
-          this.value = '';
-          if (typeof e === 'string') {
-            this.queryErrorMessage = e;
-          } else if (e instanceof Error) {
-            this.queryErrorMessage = e.message;
-          }
-        });
-    };
-
+    const queryId = GrAutocomplete.getNextQueryId();
+    this.activeQueryId = queryId;
+    this.setQueryStatus({
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
     this.updateSuggestionsTask = debounce(
       this.updateSuggestionsTask,
-      update,
+      this.createUpdateTask(queryId, this.query, this.text),
       DEBOUNCE_WAIT_MS
     );
   }
 
+  private createUpdateTask(
+    queryId: number,
+    query: AutocompleteQuery,
+    text: string
+  ): () => Promise<void> {
+    return async () => {
+      let suggestions: AutocompleteSuggestion[];
+      try {
+        suggestions = await query(text);
+      } catch (e) {
+        this.value = '';
+        if (typeof e === 'string') {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e,
+          });
+        } else if (e instanceof Error) {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e.message,
+          });
+        }
+        return;
+      }
+      if (queryId !== this.activeQueryId) {
+        // Late response.
+        return;
+      }
+      for (const suggestion of suggestions) {
+        suggestion.text = suggestion?.name ?? '';
+      }
+      this.setSuggestions(suggestions);
+      if (this.index === -1) {
+        this.value = '';
+      }
+    };
+  }
+
   setFocus(focused: boolean) {
     if (focused === this.focused) return;
     this.focused = focused;
     this.updateDropdownVisibility();
   }
 
+  private shouldShowDropdown() {
+    return (this.suggestions.length > 0 || this.queryStatus) && this.focused;
+  }
+
   updateDropdownVisibility() {
-    if (
-      (this.suggestions.length > 0 || this.queryErrorMessage) &&
-      this.focused
-    ) {
+    if (this.shouldShowDropdown()) {
       this.suggestionsDropdown?.open();
       return;
     }
@@ -511,10 +543,26 @@
         this.cancel();
         break;
       case 'Tab':
-        if (this.suggestions.length > 0 && this.tabComplete) {
+        if (
+          this.queryStatus?.type === AutocompleteQueryStatusType.LOADING &&
+          this.tabComplete
+        ) {
           e.preventDefault();
+          // Queue tab on load.
+          this.queryStatus = {
+            type: AutocompleteQueryStatusType.LOADING,
+            message: 'Loading... (Handle Tab on load)',
+          };
+          const queryId = this.activeQueryId;
+          this.latestSuggestionUpdateComplete?.then(() => {
+            if (queryId === this.activeQueryId) {
+              this.handleInputCommit(/* _tabComplete=*/ true);
+            }
+          });
+        } else if (this.suggestions.length > 0 && this.tabComplete) {
+          e.preventDefault();
+          this.handleInputCommit(/* _tabComplete=*/ true);
           this.focus();
-          this.handleInputCommit(true);
         } else {
           this.setFocus(false);
         }
@@ -523,12 +571,24 @@
         if (modifierPressed(e)) {
           break;
         }
-        if (this.suggestions.length > 0) {
+        e.preventDefault();
+        if (this.queryStatus?.type === AutocompleteQueryStatusType.LOADING) {
+          // Queue enter on load.
+          this.queryStatus = {
+            type: AutocompleteQueryStatusType.LOADING,
+            message: 'Loading... (Handle Enter on load)',
+          };
+          const queryId = this.activeQueryId;
+          this.latestSuggestionUpdateComplete?.then(() => {
+            if (queryId === this.activeQueryId) {
+              this.handleItemSelectEnter(e);
+            }
+          });
+        } else if (this.suggestions.length > 0) {
           // If suggestions are shown, act as if the keypress is in dropdown.
           // suggestions length is 0 if error is shown.
           this.handleItemSelectEnter(e);
         } else {
-          e.preventDefault();
           this.handleInputCommit();
         }
         break;
@@ -541,24 +601,20 @@
         // been based on a previous input. Clear them. This prevents an
         // outdated suggestion from being used if the input keystroke is
         // immediately followed by a commit keystroke. @see Issue 8655
-        this.suggestions = [];
+        this.resetQueryOutput();
+        this.activeQueryId = 0;
     }
-    this.dispatchEvent(
-      new CustomEvent('input-keydown', {
-        detail: {key: e.key, input: this.input},
-        composed: true,
-        bubbles: true,
-      })
-    );
   }
 
   cancel() {
-    if (this.suggestions.length || this.queryErrorMessage) {
-      this.suggestions = [];
-      this.queryErrorMessage = undefined;
+    if (this.shouldShowDropdown()) {
+      this.resetQueryOutput();
+      // If query is in flight by setting id to 0 we indicate that the results
+      // are outdated.
+      this.activeQueryId = 0;
       this.requestUpdate();
     } else {
-      fireEvent(this, 'cancel');
+      fire(this, 'cancel', {});
     }
   }
 
@@ -566,7 +622,7 @@
     // Nothing to do if no suggestions.
     if (
       !this.allowNonSuggestedValues &&
-      (this.suggestionsDropdown?.isHidden || this.queryErrorMessage)
+      (this.suggestionsDropdown?.isHidden || this.suggestions.length === 0)
     ) {
       return;
     }
@@ -608,6 +664,7 @@
       }
     }
     this.setFocus(false);
+    this.activeQueryId = 0;
   };
 
   /**
@@ -644,21 +701,31 @@
       }
     }
 
-    this.suggestions = [];
-    this.queryErrorMessage = undefined;
+    this.resetQueryOutput();
     // we need willUpdate to send text-changed event before we can send the
     // 'commit' event
     await this.updateComplete;
     if (!silent) {
-      this.dispatchEvent(
-        new CustomEvent('commit', {
-          detail: {value} as AutocompleteCommitEventDetail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'commit', {value});
     }
   }
+
+  // resetQueryOutput, setSuggestions and setQueryStatus insure that suggestions
+  // and queryStatus are never set at the same time.
+  private resetQueryOutput() {
+    this.suggestions = [];
+    this.queryStatus = undefined;
+  }
+
+  private setSuggestions(suggestions: AutocompleteSuggestion[]) {
+    this.suggestions = suggestions;
+    this.queryStatus = undefined;
+  }
+
+  private setQueryStatus(queryStatus: AutocompleteQueryStatus) {
+    this.suggestions = [];
+    this.queryStatus = queryStatus;
+  }
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index 81949c7..0cef331 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -8,11 +8,15 @@
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
 import {
   assertFails,
+  mockPromise,
   pressKey,
   queryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html, assert} from '@open-wc/testing';
 import {Key, Modifier} from '../../../utils/dom-util';
@@ -30,9 +34,7 @@
   const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
 
   setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete no-debounce></gr-autocomplete>`
-    );
+    element = await fixture(html`<gr-autocomplete></gr-autocomplete>`);
   });
 
   test('renders', () => {
@@ -151,7 +153,10 @@
         ],
       }
     );
-    assert.equal(element.suggestionsDropdown?.errorMessage, 'blah not allowed');
+    assert.equal(
+      element.suggestionsDropdown?.queryStatus?.message,
+      'blah not allowed'
+    );
   });
 
   test('cursor starts on suggestions', async () => {
@@ -240,17 +245,21 @@
     await element.updateComplete;
 
     return assertFails(promise).then(async () => {
+      await element.latestSuggestionUpdateComplete;
       await waitUntil(() => !suggestionsEl().isHidden);
 
       const cancelHandler = sinon.spy();
       element.addEventListener('cancel', cancelHandler);
-      assert.equal(element.queryErrorMessage, 'Test error');
+      assert.deepEqual(element.queryStatus, {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Test error',
+      });
 
       pressKey(inputEl(), Key.ESC);
       await waitUntil(() => suggestionsEl().isHidden);
 
       assert.isFalse(cancelHandler.called);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
 
       pressKey(inputEl(), Key.ESC);
       await element.updateComplete;
@@ -260,16 +269,14 @@
   });
 
   test('emits commit and handles cursor movement', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
-    const queryStub = sinon.spy(
-      (input: string) =>
-        (promise = Promise.resolve([
-          {name: input + ' 0', value: '0'},
-          {name: input + ' 1', value: '1'},
-          {name: input + ' 2', value: '2'},
-          {name: input + ' 3', value: '3'},
-          {name: input + ' 4', value: '4'},
-        ] as AutocompleteSuggestion[]))
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
     );
     element.query = queryStub;
     await element.updateComplete;
@@ -280,7 +287,7 @@
     element.text = 'blah';
     await element.updateComplete;
 
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => !suggestionsEl().isHidden);
 
       const commitHandler = sinon.spy();
@@ -456,24 +463,22 @@
   });
 
   test('suggestions should not carry over', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
-      .returns(
-        (promise = Promise.resolve([
-          {name: 'suggestion', value: '0'},
-        ] as AutocompleteSuggestion[]))
-      );
+      .resolves([{name: 'suggestion', value: '0'}] as AutocompleteSuggestion[]);
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
     await element.updateComplete;
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => element.suggestions.length > 0);
       assert.equal(element.suggestions.length, 1);
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
       element.text = '';
       element.threshold = 0;
       await element.updateComplete;
+      await element.latestSuggestionUpdateComplete;
       assert.equal(element.suggestions.length, 0);
     });
   });
@@ -488,11 +493,15 @@
     element.text = 'bla';
     await element.updateComplete;
     return assertFails(promise).then(async () => {
-      await waitUntil(() => element.queryErrorMessage === 'Test error');
+      await element.latestSuggestionUpdateComplete;
+      await waitUntil(() => element.queryStatus?.message === 'Test error');
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
       element.text = '';
       element.threshold = 0;
       await element.updateComplete;
-      assert.isUndefined(element.queryErrorMessage);
+      await element.latestSuggestionUpdateComplete;
+      assert.isUndefined(element.queryStatus);
     });
   });
 
@@ -514,6 +523,7 @@
     return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
+      await element.latestSuggestionUpdateComplete;
       await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
       pressKey(inputEl(), Key.ENTER);
@@ -524,15 +534,24 @@
   });
 
   test('tabComplete flag functions', async () => {
+    element.query = sinon
+      .stub()
+      .resolves([
+        {name: 'tunnel snakes rule!', value: 'snakes'},
+      ] as AutocompleteSuggestion[]);
+
     // commitHandler checks for the commit event, whereas commitSpy checks for
     // the _commit function of the element.
     const commitHandler = sinon.spy();
     element.addEventListener('commit', commitHandler);
     const commitSpy = sinon.spy(element, '_commit');
     element.setFocus(true);
-
-    element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
+    element.text = 'text1';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
     await element.updateComplete;
 
@@ -540,9 +559,12 @@
     assert.isFalse(commitSpy.called);
     assert.isFalse(element.focused);
 
-    element.tabComplete = true;
-    await element.updateComplete;
     element.setFocus(true);
+    element.tabComplete = true;
+    element.text = 'text2';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
     await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
 
@@ -597,7 +619,11 @@
       ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
-      element.queryErrorMessage = 'Error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
       element.handleInputCommit();
       assert.isFalse(commitStub.called);
     }
@@ -620,7 +646,11 @@
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
-      element.queryErrorMessage = 'Error';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
       element.handleInputCommit();
       assert.isTrue(commitStub.called);
     }
@@ -629,6 +659,7 @@
   test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
+    element.suggestions = [{name: 'first suggestion'}];
     element.handleInputCommit();
     assert.isTrue(commitStub.calledOnce);
   });
@@ -671,6 +702,215 @@
     assert.equal(element.text, 'file:x');
   });
 
+  test('render loading replace with suggestions when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 1);
+    assert.isUndefined(element.queryStatus);
+  });
+
+  test('render loading replace with error when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.reject(new Error('Test error'));
+    await assertFails(queryPromise);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.ERROR,
+      message: 'Test error',
+    });
+  });
+
+  test('render loading esc cancels', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    pressKey(inputEl(), Key.ESC);
+    await waitUntil(() => suggestionsEl().isHidden);
+
+    assert.isFalse(cancelHandler.called);
+    assert.isUndefined(element.queryStatus);
+
+    pressKey(inputEl(), Key.ESC);
+    await element.updateComplete;
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('while loading queue enter commits', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    let resolvePromise: (value: AutocompleteSuggestion[]) => void;
+    const blockingPromise = new Promise<AutocompleteSuggestion[]>(resolve => {
+      resolvePromise = resolve;
+    });
+    element.query = (_: string) => blockingPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    resolvePromise!([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+    assert.isTrue(commitHandler.called);
+  });
+
+  test('while loading queue tab completes', async () => {
+    element.tabComplete = true;
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.TAB);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Tab on load)',
+    });
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+    assert.isFalse(commitHandler.called);
+    assert.equal(element.text, 'suggestion 1');
+  });
+
+  test('while loading and queued update text cancels', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    element.text = 'more blah';
+    await element.updateComplete;
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    // Commit for stale request is not called.
+    assert.isFalse(commitHandler.called);
+  });
+
+  test('while loading and queued esc cancels', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    pressKey(inputEl(), Key.ESC);
+    await element.updateComplete;
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    // Commit for stale request is not called.
+    assert.isFalse(commitHandler.called);
+    // Query results and status are cleared
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+  });
+
   suite('focus', () => {
     let commitSpy: sinon.SinonSpy;
     let focusSpy: sinon.SinonSpy;
@@ -688,13 +928,16 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
     test('enter in input does not re-render error', async () => {
       element.allowNonSuggestedValues = true;
-      element.queryErrorMessage = 'Error message';
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error message',
+      };
 
       pressKey(inputEl(), Key.ENTER);
 
@@ -702,7 +945,7 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
-      assert.isUndefined(element.queryErrorMessage);
+      assert.isUndefined(element.queryStatus);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
@@ -820,27 +1063,14 @@
     });
   });
 
-  test('input-keydown event fired', async () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    pressKey(inputEl(), Key.TAB);
-    await element.updateComplete;
-    assert.isTrue(listener.called);
-  });
-
   test('enter with modifier does not complete', async () => {
-    const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
     const commitStub = sinon.stub(element, 'handleInputCommit');
+
     pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
     await element.updateComplete;
 
-    assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
-    assert.equal(
-      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
-      Key.ENTER
-    );
-
     assert.isFalse(commitStub.called);
+
     pressKey(inputEl(), Key.ENTER);
     await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0bb451c..54fb825 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -14,6 +14,7 @@
 import {resolve} from '../../../models/dependency';
 import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
 import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -93,12 +94,6 @@
       change: this.change,
       starred: newVal,
     };
-    this.dispatchEvent(
-      new CustomEvent('toggle-star', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
+    fire(this, 'toggle-star', detail);
   }
 }
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 c844d42..0edfe03 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -44,7 +44,7 @@
   ReplyToCommentEventDetail,
   ValueChangedEvent,
 } from '../../../types/events';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {assertIsDefined, assert} from '../../../utils/common-util';
 import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -275,6 +275,9 @@
         this.save();
       });
     }
+    this.addEventListener('open-user-suggest-preview', e => {
+      this.handleShowFix(e.detail.code);
+    });
     this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
@@ -524,7 +527,6 @@
             ${this.renderCommentMessage()}
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
-            ${this.renderSuggestEditActions()}
           </div>
         </div>
       </gr-endpoint-decorator>
@@ -776,32 +778,13 @@
     return html`
       <div class="rightActions">
         ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
-        ${this.renderDiscardButton()} ${this.renderPreviewSuggestEditButton()}
-        ${this.renderEditButton()} ${this.renderCancelButton()}
-        ${this.renderSaveButton()} ${this.renderCopyLinkIcon()}
+        ${this.renderDiscardButton()} ${this.renderEditButton()}
+        ${this.renderCancelButton()} ${this.renderSaveButton()}
+        ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
 
-  private renderPreviewSuggestEditButton() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    assertIsDefined(this.comment, 'comment');
-    if (!hasUserSuggestion(this.comment)) return nothing;
-    return html`
-      <gr-button
-        link
-        secondary
-        class="action show-fix"
-        ?disabled=${this.saving}
-        @click=${this.handleShowFix}
-      >
-        Preview Fix
-      </gr-button>
-    `;
-  }
-
   private renderSuggestEditButton() {
     if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
       return nothing;
@@ -892,22 +875,6 @@
     `;
   }
 
-  private renderSuggestEditActions() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    if (
-      !this.account ||
-      isRobot(this.comment) ||
-      isDraftOrUnsaved(this.comment)
-    ) {
-      return nothing;
-    }
-    return html`
-      <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
-    `;
-  }
-
   private renderShowFixButton() {
     if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
     return html`
@@ -1015,7 +982,7 @@
   }
 
   private handleCopyLink() {
-    fireEvent(this, 'copy-comment-link');
+    fire(this, 'copy-comment-link', {});
   }
 
   /** Enter editing mode. */
@@ -1037,12 +1004,14 @@
   }
 
   // private, but visible for testing
-  async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
+  async createFixPreview(
+    replacement?: string
+  ): Promise<OpenFixPreviewEventDetail> {
     assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
     assertIsDefined(this.comment?.path, 'comment.path');
 
-    if (hasUserSuggestion(this.comment)) {
-      const replacement = getUserSuggestion(this.comment);
+    if (hasUserSuggestion(this.comment) || replacement) {
+      replacement = replacement ?? getUserSuggestion(this.comment);
       assert(!!replacement, 'malformed user suggestion');
       const line = await this.getCommentedCode();
 
@@ -1150,9 +1119,9 @@
     fire(this, 'reply-to-comment', eventDetail);
   }
 
-  private async handleShowFix() {
+  private async handleShowFix(replacement?: string) {
     // Handled top-level in the diff and change view components.
-    fire(this, 'open-fix-preview', await this.createFixPreview());
+    fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
   }
 
   async createSuggestEdit(e: MouseEvent) {
@@ -1316,4 +1285,7 @@
   interface HTMLElementTagNameMap {
     'gr-comment': GrComment;
   }
+  interface HTMLElementEventMap {
+    'copy-comment-link': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 3390369..6625844 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -29,19 +29,12 @@
 import {
   createComment,
   createDraft,
-  createFixSuggestionInfo,
   createRobotComment,
   createUnsaved,
 } from '../../../test/test-data-generators';
-import {
-  ReplyToCommentEvent,
-  OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-import {
-  DraftInfo,
-  USER_SUGGESTION_START_PATTERN,
-} from '../../../utils/comment-util';
+import {DraftInfo} from '../../../utils/comment-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Modifier} from '../../../utils/dom-util';
 import {SinonStub} from 'sinon';
@@ -747,23 +740,6 @@
       actions = query(element, '.robotActions gr-button.fix');
       assert.isNotOk(actions);
     });
-
-    test('handleShowFix fires open-fix-preview event', async () => {
-      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
-        element,
-        'open-fix-preview'
-      );
-      element.comment = {
-        ...createRobotComment(),
-        fix_suggestions: [{...createFixSuggestionInfo()}],
-      };
-      await element.updateComplete;
-
-      queryAndAssert<GrButton>(element, '.show-fix').click();
-
-      const e = await listener;
-      assert.deepEqual(e.detail, await element.createFixPreview());
-    });
   });
 
   suite('auto saving', () => {
@@ -869,33 +845,5 @@
         </gr-button> `
       );
     });
-
-    test('renders preview suggest fix', async () => {
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
-      };
-      await element.updateComplete;
-
-      assert.dom.equal(
-        queryAndAssert(element, 'gr-button.show-fix'),
-        /* HTML */ `<gr-button
-          aria-disabled="false"
-          class="action show-fix"
-          link=""
-          role="button"
-          secondary
-          tabindex="0"
-        >
-          Preview Fix
-        </gr-button> `
-      );
-    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 285a41a..e8ab172 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -11,6 +11,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -108,23 +109,12 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 350aa7f..8c280c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -66,7 +66,10 @@
           color: var(--primary-text-color);
         }
         gr-icon {
-          color: var(--deemphasized-text-color);
+          color: var(
+            --gr-copy-clipboard-icon-color,
+            var(--deemphasized-text-color)
+          );
         }
         gr-button {
           display: block;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 04d5923..6536806 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -10,6 +10,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {when} from 'lit/directives/when.js';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -199,23 +200,13 @@
 
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   _handleKeydown(e: KeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index b6ca9f5..c935e72 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -23,6 +23,7 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {when} from 'lit/directives/when.js';
 import {isMagicPath} from '../../../utils/path-list-util';
+import {fireNoBubble} from '../../../utils/event-util';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -303,12 +304,7 @@
     this.text = selectedObj.triggerText
       ? selectedObj.triggerText
       : selectedObj.text;
-    this.dispatchEvent(
-      new CustomEvent('value-change', {
-        detail: {value: this.value},
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'value-change', {value: this.value});
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 3a8946a..495b448 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -242,7 +242,8 @@
         allowOutsideScroll
         .horizontalAlign=${this.horizontalAlign}
         @click=${() => this.close()}
-        @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)}
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+          (this.opened = e.detail.value)}
       >
         ${this.renderDropdownContent()}
       </iron-dropdown>`;
@@ -460,13 +461,7 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(
-          new CustomEvent('tap-item', {
-            detail: item,
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fire(this, 'tap-item', item);
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
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 e176598..3110a96 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
@@ -10,7 +10,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
@@ -21,7 +21,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
 import {PropertyValues} from 'lit';
-import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {
+  BindValueChangeEvent,
+  EditableContentSaveEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {nothing} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
@@ -39,24 +43,16 @@
   interface HTMLElementEventMap {
     'content-changed': ValueChangedEvent<string>;
     'editing-changed': ValueChangedEvent<boolean>;
+    /** Fired when the 'cancel' button is pressed. */
+    'editable-content-cancel': CustomEvent<{}>;
+    /** Fired when the 'save' button is pressed. */
+    'editable-content-save': EditableContentSaveEvent;
   }
 }
 
 @customElement('gr-editable-content')
 export class GrEditableContent extends LitElement {
   /**
-   * Fired when the save button is pressed.
-   *
-   * @event editable-content-save
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event editable-content-cancel
-   */
-
-  /**
    * Fired when content is restored from storage.
    *
    * @event show-alert
@@ -386,13 +382,7 @@
 
   handleSave(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('editable-content-save', {
-        detail: {content: this.newContent},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'editable-content-save', {content: this.newContent});
     // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
@@ -401,7 +391,7 @@
   handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
-    fireEvent(this, 'editable-content-cancel');
+    fire(this, 'editable-content-cancel', {});
   }
 
   toggleCommitCollapsed() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index b4f25ce..4a5611b 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -9,7 +9,6 @@
 import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {StorageService} from '../../../services/storage/gr-storage';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {testResolver} from '../../../test/common-test-setup';
@@ -190,7 +189,7 @@
       await element.updateComplete;
       assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, EventType.SHOW_ALERT);
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
     });
 
     test('editing toggled to true, has no stored data', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index bf8209b..c8574cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -21,6 +21,8 @@
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {IronInputElement} from '@polymer/iron-input';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -207,7 +209,7 @@
         .text=${this.inputText}
         .query=${this.query}
         @cancel=${this.cancel}
-        @text-changed=${(e: CustomEvent) => {
+        @text-changed=${(e: ValueChangedEvent) => {
           this.inputText = e.detail.value;
         }}
       >
@@ -308,13 +310,8 @@
       this.value = this.inputText || '';
     }
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('changed', {
-        detail: this.value,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    // TODO: This event seems to be unused (no listener). Remove?
+    fire(this, 'changed', this.value);
   }
 
   private cancel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index d916118..3bb058e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -295,7 +295,10 @@
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
 
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
 
       pressKey(autocomplete.input!, Key.ENTER);
 
@@ -312,7 +315,11 @@
     test('autocomplete suggestions closed enter saves suggestion', async () => {
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
+
       // Press enter to close suggestions.
       pressKey(autocomplete.input!, Key.ENTER);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 45eca40..023d8b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,6 +18,10 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
+import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
+import {USER_SUGGESTION_INFO_STRING} from '../../../utils/comment-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -34,6 +38,8 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -134,6 +140,10 @@
   }
 
   private renderAsMarkdown() {
+    // need to find out here, since customRender is not arrow function
+    const suggestEditsEnable = this.flagsService.isEnabled(
+      KnownExperimentId.SUGGEST_EDIT
+    );
     // <marked-element> internals will be in charge of calling our custom
     // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
     // closure.
@@ -167,7 +177,18 @@
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
         `<code>${unescapeHTML(text)}</code>`;
-      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['code'] = (text: string, infostring: string) => {
+        if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+          // default santizer in markedjs is very restrictive, we need to use
+          // existing html element to mark element. We cannot use css class for it.
+          // Therefore we pick mark - as not frequently used html element to represent
+          // unconverted gr-user-suggestion-fix.
+          // TODO(milutin): Find a way to override sanitizer to directly use gr-user-suggestion-fix
+          return `<mark>${text}</mark>`;
+        } else {
+          return `<pre><code>${text}</code></pre>`;
+        }
+      };
       renderer['text'] = boundRewriteText;
     }
 
@@ -211,6 +232,9 @@
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
     this.convertEmailsToAccountChips();
+    if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      this.convertCodeToSuggestions();
+    }
   }
 
   private convertEmailsToAccountChips() {
@@ -235,6 +259,17 @@
       }
     }
   }
+
+  private convertCodeToSuggestions() {
+    for (const userSuggestionMark of this.renderRoot.querySelectorAll('mark')) {
+      const userSuggestion = document.createElement('gr-user-suggestion-fix');
+      userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+      userSuggestionMark.parentNode?.replaceChild(
+        userSuggestion,
+        userSuggestionMark
+      );
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index fcebeea..0e5117a 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -587,5 +587,25 @@
         `
       );
     });
+
+    suite('user suggest fix', () => {
+      setup(async () => {
+        const flagsService = getAppContext().flagsService;
+        sinon.stub(flagsService, 'isEnabled').returns(true);
+      });
+
+      test('renders', async () => {
+        element.content = '```suggestion\nHello World```';
+        await element.updateComplete;
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `<marked-element>
+            <div class="markdown-html" slot="markdown-html">
+              <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+            </div>
+          </marked-element>`
+        );
+      });
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index 2730fcf..14c2c15 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -38,13 +38,12 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement, nothing} from 'lit';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {EventType} from '../../../types/events';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {createSearchUrl} from '../../../models/views/search';
 import {createDashboardUrl} from '../../../models/views/dashboard';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-hovercard-account-contents')
@@ -251,10 +250,10 @@
       <a
         href=${ifDefined(this.computeOwnerChangesLink())}
         @click=${() => {
-          fireEvent(this, 'link-clicked');
+          fire(this, 'link-clicked', {});
         }}
         @enter=${() => {
-          fireEvent(this, 'link-clicked');
+          fire(this, 'link-clicked', {});
         }}
       >
         Changes
@@ -263,10 +262,10 @@
       <a
         href=${ifDefined(this.computeOwnerDashboardLink())}
         @click=${() => {
-          fireEvent(this, 'link-clicked');
+          fire(this, 'link-clicked', {});
         }}
         @enter=${() => {
-          fireEvent(this, 'link-clicked');
+          fire(this, 'link-clicked', {});
         }}
       >
         Dashboard
@@ -423,7 +422,7 @@
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
     const _accountKey = accountKey(this.account);
-    fire(this, EventType.SHOW_ALERT, {
+    fire(this, 'show-alert', {
       message: 'Reloading page...',
     });
     const reviewInput: Partial<ReviewInput> = {};
@@ -453,7 +452,7 @@
   private handleRemoveReviewerOrCC() {
     if (!this.change || !(this.account?._account_id || this.account?.email))
       throw new Error('Missing change or account.');
-    fire(this, EventType.SHOW_ALERT, {
+    fire(this, 'show-alert', {
       message: 'Reloading page...',
     });
     this.restApiService
@@ -486,7 +485,7 @@
 
   private handleClickAddToAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    fire(this, EventType.SHOW_ALERT, {
+    fire(this, 'show-alert', {
       message: 'Reloading page...',
       dismissOnNavigation: true,
     });
@@ -501,7 +500,7 @@
       reason,
       reason_account: this.selfAccount,
     };
-    fireEvent(this, 'attention-set-updated');
+    fire(this, 'attention-set-updated', {});
 
     this.reporting.reportInteraction(
       'attention-hovercard-add',
@@ -510,14 +509,14 @@
     this.restApiService
       .addToAttentionSet(this.change._number, this.account._account_id, reason)
       .then(() => {
-        fireEvent(this, 'hide-alert');
+        fire(this, 'hide-alert', {});
       });
-    fireEvent(this, 'action-taken');
+    fire(this, 'action-taken', {});
   }
 
   private handleClickRemoveFromAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    fire(this, EventType.SHOW_ALERT, {
+    fire(this, 'show-alert', {
       message: 'Saving attention set update ...',
       dismissOnNavigation: true,
     });
@@ -528,7 +527,7 @@
     const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
-    fireEvent(this, 'attention-set-updated');
+    fire(this, 'attention-set-updated', {});
 
     this.reporting.reportInteraction(
       'attention-hovercard-remove',
@@ -541,9 +540,9 @@
         reason
       )
       .then(() => {
-        fireEvent(this, 'hide-alert');
+        fire(this, 'hide-alert', {});
       });
-    fireEvent(this, 'action-taken');
+    fire(this, 'action-taken', {});
   }
 
   private reportingDetails() {
@@ -572,4 +571,9 @@
   interface HTMLElementTagNameMap {
     'gr-hovercard-account-contents': GrHovercardAccountContents;
   }
+  interface HTMLElementEventMap {
+    'action-taken': CustomEvent<{}>;
+    'attention-set-updated': CustomEvent<{}>;
+    'link-clicked': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
index bd47ec8..7df06f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -26,7 +26,6 @@
   createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
 import {GrButton} from '../gr-button/gr-button';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
 import {userModelToken} from '../../../models/user/user-model';
 
@@ -308,7 +307,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('show-alert', showAlertListener);
     element.addEventListener('hide-alert', hideAlertListener);
     element.addEventListener('attention-set-updated', updatedListener);
 
@@ -359,7 +358,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('show-alert', showAlertListener);
     element.addEventListener('hide-alert', hideAlertListener);
     element.addEventListener('attention-set-updated', updatedListener);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 0628d2f..c81f586 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -4,13 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 import {getAppContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -110,13 +110,7 @@
       .send(this.action.method, this.action.__url, payload)
       .then(onSuccess)
       .catch((error: unknown) => {
-        document.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-            detail: {
-              message: `Plugin network error: ${error}`,
-            },
-          })
-        );
+        fireAlert(document, `Plugin network error: ${error}`);
       });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
index eaf6612..76a6573 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
@@ -7,7 +7,6 @@
 import './gr-js-api-interface';
 import {GrPluginActionContext} from './gr-plugin-action-context';
 import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
-import {EventType} from '../../../types/events';
 import {assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {SinonStub, stub, spy} from 'sinon';
@@ -147,7 +146,7 @@
     } as unknown as RestPluginApi;
     stub(plugin, 'restApi').returns(fakeRestApi);
     const errorStub = stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, errorStub);
+    addListenerForTest(document, 'show-alert', errorStub);
 
     instance.call({}, () => {});
     await waitEventLoop();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index acce236..b2ac2bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -11,7 +11,6 @@
 import {PluginApi} from '../../../api/plugin';
 import {SinonFakeTimers} from 'sinon';
 import {Timestamp} from '../../../api/rest-api';
-import {EventType} from '../../../types/events';
 import {assert} from '@open-wc/testing';
 import {getAppContext} from '../../../services/app-context';
 
@@ -144,7 +143,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
       pluginLoader.install(
@@ -178,7 +177,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
       pluginLoader.install(
@@ -217,7 +216,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
       pluginLoader.install(
@@ -249,7 +248,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
       pluginLoader.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 47d722d..02f830d 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -22,7 +22,7 @@
 import {customElement, property} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {
-  canVote,
+  canReviewerVote,
   getApprovalInfo,
   hasNeutralStatus,
   hasVoted,
@@ -143,7 +143,7 @@
       .filter(reviewer => {
         if (this.showAllReviewers) {
           if (isDetailedLabelInfo(labelInfo)) {
-            return canVote(labelInfo, reviewer);
+            return canReviewerVote(labelInfo, reviewer);
           } else {
             // isQuickLabelInfo
             return hasVoted(labelInfo, reviewer);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index e48dcb3..7717683 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -6,7 +6,7 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import '../gr-limited-text/gr-limited-text';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
@@ -15,6 +15,11 @@
   interface HTMLElementTagNameMap {
     'gr-linked-chip': GrLinkedChip;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the 'remove' button was clicked. */
+    // prettier-ignore
+    'remove': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-linked-chip')
@@ -101,6 +106,6 @@
 
   private handleRemoveTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'remove');
+    fire(this, 'remove', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 7e90c8d..ea45142 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -7,7 +7,7 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -22,6 +22,9 @@
   interface HTMLElementTagNameMap {
     'gr-list-view': GrListView;
   }
+  interface HTMLElementEventMap {
+    'create-clicked': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-list-view')
@@ -180,7 +183,7 @@
   }
 
   private createNewItem() {
-    fireEvent(this, 'create-clicked');
+    fire(this, 'create-clicked', {});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 8b3e2a6..615a04a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -17,7 +17,11 @@
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
-import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
+import {
+  fire,
+  fireNetworkError,
+  fireServerError,
+} from '../../../../utils/event-util';
 import {FetchRequest} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
 import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
@@ -337,13 +341,7 @@
         elapsed,
         anonymizedUrl: req.anonymizedUrl,
       };
-      document.dispatchEvent(
-        new CustomEvent('gr-rpc-log', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(document, 'gr-rpc-log', detail);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 20a6463..782c055 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -12,7 +12,7 @@
 import {
   GrAutocompleteDropdown,
   Item,
-  ItemSelectedEvent,
+  ItemSelectedEventDetail,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
@@ -67,7 +67,7 @@
 
 declare global {
   interface HTMLElementEventMap {
-    'item-selected': CustomEvent<ItemSelectedEvent>;
+    'item-selected': CustomEvent<ItemSelectedEventDetail>;
   }
 }
 
@@ -375,7 +375,7 @@
   }
 
   // private but used in test
-  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEvent>) {
+  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
     if (e.detail.selected?.dataset['value']) {
       this.setValue(e.detail.selected?.dataset['value']);
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 711866a..154ea0a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -6,7 +6,7 @@
 import '../../../test/common-test-setup';
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
-import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {ItemSelectedEventDetail} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {pressKey, stubRestApi, waitUntil} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
@@ -482,7 +482,7 @@
     element.specialCharIndex = 10;
     await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
-    const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
+    const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
       detail: {trigger: 'click', selected: selectedItem},
     });
     element.handleDropdownItemSelect(event);
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
new file mode 100644
index 0000000..c557acc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fire} from '../../../utils/event-util';
+
+declare global {
+  interface HTMLElementEventMap {
+    'open-user-suggest-preview': OpenUserSuggestionPreviewEvent;
+  }
+}
+
+export type OpenUserSuggestionPreviewEvent =
+  CustomEvent<OpenUserSuggestionPreviewEventDetail>;
+export interface OpenUserSuggestionPreviewEventDetail {
+  code: string;
+}
+
+@customElement('gr-user-suggestion-fix')
+export class GrUserSuggetionFix extends LitElement {
+  private readonly flagsService = getAppContext().flagsService;
+
+  static override styles = [
+    css`
+      .header {
+        background-color: var(--user-suggestion-header-background);
+        color: var(--user-suggestion-header-color);
+        border: 1px solid var(--border-color);
+        border-bottom: 0;
+        padding: var(--spacing-xs) var(--spacing-s);
+        display: flex;
+        align-items: center;
+      }
+      .header .title {
+        flex: 1;
+      }
+      gr-copy-clipboard {
+        --gr-copy-clipboard-icon-color: var(--user-suggestion-header-color);
+      }
+      code {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+        background-color: var(--background-color-secondary);
+        border: 1px solid var(--border-color);
+        border-top: 0;
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin-bottom: var(--spacing-m);
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (!this.textContent) return nothing;
+    const code = this.textContent;
+    return html`<div class="header">
+        <div class="title">Suggested fix</div>
+        <div>
+          <gr-copy-clipboard hideInput="" text=${code}></gr-copy-clipboard>
+        </div>
+        <div>
+          <gr-button
+            secondary
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Preview Fix
+          </gr-button>
+        </div>
+      </div>
+      <code>${code}</code>`;
+  }
+
+  handleShowFix() {
+    if (!this.textContent) return;
+    fire(this, 'open-user-suggest-preview', {code: this.textContent});
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-suggestion-fix': GrUserSuggetionFix;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
new file mode 100644
index 0000000..80422a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-user-suggestion-fix';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-user-suggestion-fix tests', () => {
+  let element: GrUserSuggetionFix;
+
+  setup(async () => {
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    element = await fixture<GrUserSuggetionFix>(html`
+      <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+    `);
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="header">
+          <div class="title">Suggested fix</div>
+          <div>
+            <gr-copy-clipboard
+              hideinput=""
+              text="Hello World"
+            ></gr-copy-clipboard>
+          </div>
+          <div>
+            <gr-button class="action show-fix" secondary=""
+              >Preview Fix</gr-button
+            >
+          </div>
+        </div>
+        <code>Hello World</code>`
+    );
+  });
+});
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 1679ee0..4a2fee5 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
@@ -161,6 +161,10 @@
       /* same as defined in gr-button */
       background: rgba(0, 0, 0, 0.12);
     }
+    paper-button:focus-visible {
+      /* paper-button sets this to 0, thus preventing focus-based styling. */
+      outline-width: 1px;
+    }
 
     .aboveBelowButtons {
       display: flex;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 12d7ec2..71a0637 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -41,7 +41,7 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireAlert, fire} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -201,7 +201,7 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.fireDiffEvent('render-start');
+    fire(this.diffElement, 'render-start', {});
     // TODO: processor.process() returns a cancelable promise already.
     // Why wrap another one around it?
     this.cancelableRenderPromise = makeCancelable(
@@ -215,7 +215,7 @@
             this.builder.renderImageDiff();
           }
           await this.untilGroupsRendered();
-          this.fireDiffEvent('render-content');
+          fire(this.diffElement, 'render-content', {});
         })
         // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
@@ -243,11 +243,6 @@
     this.replaceGroup(e.detail.contextGroup, e.detail.groups);
   };
 
-  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
-    assertIsDefined(this.diffElement, 'diff table');
-    fireEvent(this.diffElement, type);
-  }
-
   // visible for testing
   setupAnnotationLayers() {
     this.rangeLayer = new GrRangedCommentLayer();
@@ -360,12 +355,12 @@
     newGroups: readonly GrDiffGroup[]
   ) {
     if (!this.builder) return;
-    this.fireDiffEvent('render-start');
+    fire(this.diffElement, 'render-start', {});
     this.builder.replaceGroup(contextGroup, newGroups);
     this.groups = this.groups.filter(g => g !== contextGroup);
     this.groups.push(...newGroups);
     this.untilGroupsRendered(newGroups).then(() => {
-      this.fireDiffEvent('render-content');
+      fire(this.diffElement, 'render-content', {});
     });
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index 0f02d71..9cf9bae 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -30,7 +30,6 @@
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {BlameInfo} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 
 const DEFAULT_PREFS = createDefaultDiffPrefs();
 
@@ -134,7 +133,7 @@
 
   test('_handlePreferenceError triggers alert and javascript error', () => {
     const errorStub = sinon.stub();
-    diffTable.addEventListener(EventType.SHOW_ALERT, errorStub);
+    diffTable.addEventListener('show-alert', errorStub);
     assert.throws(() => element.handlePreferenceError('tab size'));
     assert.equal(
       errorStub.lastCall.args[0].detail.message,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 5270603..b3f3714 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -3,10 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  MovedLinkClickedEventDetail,
-  RenderPreferences,
-} from '../../../api/diff';
+import {RenderPreferences} from '../../../api/diff';
 import {fire} from '../../../utils/event-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
@@ -430,16 +427,10 @@
     anchor.setAttribute('href', `#${line}`);
     anchor.addEventListener('click', e => {
       e.preventDefault();
-      anchor.dispatchEvent(
-        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-          detail: {
-            lineNum: line,
-            side,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(anchor, 'moved-link-clicked', {
+        lineNum: line,
+        side,
+      });
     });
     return anchor;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index d40fdda..e5d3d2e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -9,7 +9,6 @@
   DiffInfo,
   DiffLayer,
   DiffViewMode,
-  MovedLinkClickedEventDetail,
   RenderPreferences,
   Side,
   LineNumber,
@@ -27,6 +26,7 @@
 import '../gr-range-header/gr-range-header';
 import './gr-diff-row';
 import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
 
 @customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
@@ -235,16 +235,11 @@
     side: Side,
     line: number
   ) {
-    anchor?.dispatchEvent(
-      new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-        detail: {
-          lineNum: line,
-          side,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (!anchor) return;
+    fire(anchor, 'moved-link-clicked', {
+      lineNum: line,
+      side,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 14bb17e..9e3640b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -8,7 +8,8 @@
 import {
   DiffViewMode,
   GrDiffCursor as GrDiffCursorApi,
-  LineNumberEventDetail,
+  LineNumber,
+  LineSelectedEventDetail,
 } from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
 import {toggleClass} from '../../../utils/dom-util';
@@ -19,6 +20,7 @@
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
@@ -237,7 +239,7 @@
   }
 
   moveToLineNumber(
-    number: number,
+    number: LineNumber,
     side: Side,
     path?: string,
     intentionalMove?: boolean
@@ -352,13 +354,10 @@
     this.preventAutoScrollOnManualScroll = false;
   };
 
-  private _boundHandleDiffLineSelected = (event: Event) => {
-    const customEvent = event as CustomEvent;
-    this.moveToLineNumber(
-      customEvent.detail.number,
-      customEvent.detail.side,
-      customEvent.detail.path
-    );
+  private _boundHandleDiffLineSelected = (
+    e: CustomEvent<LineSelectedEventDetail>
+  ) => {
+    this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
   };
 
   createCommentInPlace() {
@@ -485,16 +484,10 @@
     const address = this.getAddressFor(row, side);
     if (address) {
       const {leftSide, number} = address;
-      row.dispatchEvent(
-        new CustomEvent<LineNumberEventDetail>(event, {
-          detail: {
-            lineNum: number,
-            side: leftSide ? Side.LEFT : Side.RIGHT,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(row, event, {
+        lineNum: number,
+        side: leftSide ? Side.LEFT : Side.RIGHT,
+      });
     }
   }
 
@@ -580,7 +573,7 @@
   }
 
   _findRowByNumberAndFile(
-    targetNumber: number,
+    targetNumber: LineNumber,
     side: Side,
     path?: string
   ): HTMLElement | undefined {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 0714645..02f2233 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -20,6 +20,7 @@
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 interface SidedRange {
   side: Side;
@@ -458,13 +459,9 @@
   }
 
   private fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.diffTable?.dispatchEvent(
-      new CustomEvent('create-range-comment', {
-        detail: {side, range},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.diffTable) {
+      fire(this.diffTable, 'create-range-comment', {side, range});
+    }
     this.removeActionBox();
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 8a92bcc..e8575ff 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -24,14 +24,10 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 
-import {
-  createEvent,
-  Dimensions,
-  fitToFrame,
-  FrameConstrainer,
-  Point,
-  Rect,
-} from './util';
+import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {ImageDiffAction} from '../../../api/diff';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
@@ -686,27 +682,25 @@
       });
   }
 
+  fireAction(detail: ImageDiffAction) {
+    fire(this, 'image-diff-action', detail);
+  }
+
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'base'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'base'});
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'revision'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'revision'});
   }
 
   manualBlink() {
     this.toggleImage();
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'switch'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'switch'});
   }
 
   private toggleImage() {
@@ -717,9 +711,10 @@
 
   toggleAutomaticBlink() {
     this.automaticBlink = !this.automaticBlink;
-    this.dispatchEvent(
-      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
-    );
+    this.fireAction({
+      type: 'automatic-blink-changed',
+      value: this.automaticBlink,
+    });
   }
 
   private updateAutomaticBlink() {
@@ -751,52 +746,42 @@
 
   private toggleHighlight(source: 'controls' | 'magnifier') {
     this.showHighlight = !this.showHighlight;
-    this.dispatchEvent(
-      createEvent({
-        type: 'highlight-changes-changed',
-        value: this.showHighlight,
-        source,
-      })
-    );
+    this.fireAction({
+      type: 'highlight-changes-changed',
+      value: this.showHighlight,
+      source,
+    });
   }
 
-  zoomControlChanged(event: CustomEvent) {
+  zoomControlChanged(event: ValueChangedEvent<'fit' | number>) {
     const value = event.detail.value;
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: 'fit'})
-      );
+      this.fireAction({type: 'zoom-level-changed', scale: 'fit'});
     }
-    if (value > 0) {
+    if (typeof value === 'number' && value > 0) {
       this.scaledSelected = false;
       this.scale = value;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: value})
-      );
+      this.fireAction({type: 'zoom-level-changed', scale: value});
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
-    this.dispatchEvent(
-      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
-    );
+    this.fireAction({type: 'follow-mouse-changed', value: this.followMouse});
   }
 
   pickColor(value: string) {
     this.checkerboardSelected = false;
     this.backgroundColor = value;
-    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+    this.fireAction({type: 'background-color-changed', value});
   }
 
   pickCheckerboard() {
     this.checkerboardSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'background-color-changed', value: 'checkerboard'})
-    );
+    this.fireAction({type: 'background-color-changed', value: 'checkerboard'});
   }
 
   mousemoveImageArea(event: MouseEvent) {
@@ -849,9 +834,9 @@
     // external mice.
     if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
-      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+      this.fireAction({type: 'magnifier-clicked'});
     } else {
-      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+      this.fireAction({type: 'magnifier-dragged'});
     }
   }
 
@@ -894,17 +879,17 @@
     if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
-    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+    this.fireAction({type: 'magnifier-dragged'});
   }
 
   dragstartMagnifier(event: DragEvent) {
     event.preventDefault();
   }
 
-  onOverviewCenterUpdated(event: CustomEvent) {
+  onOverviewCenterUpdated(event: CustomEvent<Point>) {
     this.frameConstrainer.requestCenter({
-      x: event.detail.x as number,
-      y: event.detail.y as number,
+      x: event.detail.x,
+      y: event.detail.y,
     });
     this.updateFrames();
   }
@@ -955,4 +940,7 @@
   interface HTMLElementTagNameMap {
     'gr-image-viewer': GrImageViewer;
   }
+  interface HTMLElementEventMap {
+    'image-diff-action': CustomEvent<ImageDiffAction>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 1bc1447..21a7cf8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -7,8 +7,9 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 import {ImageDiffAction} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
 
-import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
+import {Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -243,7 +244,7 @@
     const detail: ImageDiffAction = {
       type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
     };
-    this.dispatchEvent(createEvent(detail));
+    fire(this, 'image-diff-action', detail);
 
     this.dragging = false;
     this.closeOverlay();
@@ -297,13 +298,7 @@
   }
 
   private notifyNewCenter(center: Point) {
-    this.dispatchEvent(
-      new CustomEvent('center-updated', {
-        detail: {...center},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'center-updated', {...center});
   }
 }
 
@@ -311,4 +306,7 @@
   interface HTMLElementTagNameMap {
     'gr-overview-image': GrOverviewImage;
   }
+  interface HTMLElementEventMap {
+    'center-updated': CustomEvent<Point>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
index 38a07b7..896dc11 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -3,7 +3,6 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {ImageDiffAction} from '../../../api/diff';
 
 export interface Point {
   x: number;
@@ -224,13 +223,3 @@
     };
   }
 }
-
-export function createEvent(
-  detail: ImageDiffAction
-): CustomEvent<ImageDiffAction> {
-  return new CustomEvent('image-diff-action', {
-    detail,
-    bubbles: true,
-    composed: true,
-  });
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index b9a01ce..34e9d5a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -29,7 +29,10 @@
 } from './gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+  CreateRangeCommentEventDetail,
+  GrDiffHighlight,
+} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
   getLineNumberCellWidth,
@@ -42,7 +45,7 @@
   Side,
 } from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
 import {AbortStop} from '../../../api/core';
@@ -989,8 +992,10 @@
   constructor() {
     super();
     provide(this, diffModelToken, () => this.diffModel);
-    this.addEventListener('create-range-comment', (e: Event) =>
-      this.handleCreateRangeComment(e as CustomEvent)
+    this.addEventListener(
+      'create-range-comment',
+      (e: CustomEvent<CreateRangeCommentEventDetail>) =>
+        this.handleCreateRangeComment(e)
     );
     this.addEventListener('render-content', () => this.handleRenderContent());
     this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
@@ -1268,10 +1273,10 @@
     threadEl: GrDiffThreadElement
   ) {
     hoverEl.addEventListener('mouseenter', () => {
-      fireEvent(threadEl, 'comment-thread-mouseenter');
+      fire(threadEl, 'comment-thread-mouseenter', {});
     });
     hoverEl.addEventListener('mouseleave', () => {
-      fireEvent(threadEl, 'comment-thread-mouseleave');
+      fire(threadEl, 'comment-thread-mouseleave', {});
     });
   }
 
@@ -1343,17 +1348,11 @@
   }
 
   private dispatchSelectedLine(number: LineNumber, side: Side) {
-    this.dispatchEvent(
-      new CustomEvent('line-selected', {
-        detail: {
-          number,
-          side,
-          path: this.path,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'line-selected', {
+      number,
+      side,
+      path: this.path,
+    });
   }
 
   addDraftAtLine(el: Element) {
@@ -1386,7 +1385,9 @@
     }
   }
 
-  private handleCreateRangeComment(e: CustomEvent) {
+  private handleCreateRangeComment(
+    e: CustomEvent<CreateRangeCommentEventDetail>
+  ) {
     const range = e.detail.range;
     const side = e.detail.side;
     this.createCommentForSelection(side, range);
@@ -1403,18 +1404,12 @@
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
-    this.dispatchEvent(
-      new CustomEvent<CreateCommentEventDetail>('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          path: this.path,
-          side,
-          lineNum,
-          range,
-        },
-      })
-    );
+    fire(this, 'create-comment', {
+      path: this.path,
+      side,
+      lineNum,
+      range,
+    });
   }
 
   private getCommentSideByLineAndContent(
@@ -1566,10 +1561,10 @@
     // (client), although it was not actually rendered. Clients need to know
     // when it is safe to perform operations like cursor moves, for example,
     // and if changing an input actually requires a reload of the diff table.
-    // Since `fireEvent` is synchronous it allows clients to be aware when an
+    // Since `fire` is synchronous it allows clients to be aware when an
     // async render is needed and that they can wait for a further `render`
     // event to actually take further action.
-    fireEvent(this, 'render-required');
+    fire(this, 'render-required', {});
     this.renderDiffTableTask = debounceP(
       this.renderDiffTableTask,
       async () => await this.renderDiffTable()
@@ -1584,7 +1579,7 @@
   async renderDiffTable() {
     this.unobserveNodes();
     if (!this.diff || !this.prefs) {
-      fireEvent(this, 'render');
+      fire(this, 'render', {});
       return;
     }
     if (
@@ -1594,7 +1589,7 @@
       this.safetyBypass === null
     ) {
       this.showWarning = true;
-      fireEvent(this, 'render');
+      fire(this, 'render', {});
       return;
     }
 
@@ -1638,7 +1633,7 @@
     this.observeNodes();
     // We are just converting 'render-content' into 'render' here. Maybe we
     // should retire the 'render' event in favor of 'render-content'?
-    fireEvent(this, 'render');
+    fire(this, 'render', {});
   }
 
   private observeNodes() {
@@ -1831,6 +1826,9 @@
     'gr-diff': GrDiff;
   }
   interface HTMLElementEventMap {
+    'comment-thread-mouseenter': CustomEvent<{}>;
+    'comment-thread-mouseleave': CustomEvent<{}>;
     'loading-changed': ValueChangedEvent<boolean>;
+    'render-required': CustomEvent<{}>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index cb08e55..68aa3b4 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -5,7 +5,7 @@
  */
 import '../../../elements/shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -14,16 +14,14 @@
   interface HTMLElementTagNameMap {
     'gr-selection-action-box': GrSelectionActionBox;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the comment creation action was taken (click). */
+    'create-comment-requested': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-selection-action-box')
 export class GrSelectionActionBox extends LitElement {
-  /**
-   * Fired when the comment creation action was taken (click).
-   *
-   * @event create-comment-requested
-   */
-
   @query('#tooltip')
   tooltip?: GrTooltip;
 
@@ -133,6 +131,6 @@
     } // 0 = main button
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'create-comment-requested');
+    fire(this, 'create-comment-requested', {});
   }
 }
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index b383fd7..4f79e4c 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -6,7 +6,7 @@
 import {Constructor} from '../../utils/common-util';
 import {LitElement, PropertyValues} from 'lit';
 import {property, query} from 'lit/decorators.js';
-import {EventType, ShowAlertEventDetail} from '../../types/events';
+import {ShowAlertEventDetail} from '../../types/events';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -303,7 +303,7 @@
     dispatchEventThroughTarget(eventName: string): void;
 
     dispatchEventThroughTarget(
-      eventName: EventType.SHOW_ALERT,
+      eventName: 'show-alert',
       detail: ShowAlertEventDetail
     ): void;
 
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 446822f..ad5b217 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -26,6 +26,7 @@
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
+  computeLatestPatchNumWithEdit,
 } from '../../utils/patch-set-util';
 import {ParsedChangeInfo} from '../../types/types';
 import {fireAlert} from '../../utils/event-util';
@@ -191,6 +192,10 @@
     computeLatestPatchNum(patchsets)
   );
 
+  public readonly latestPatchNumWithEdit$ = select(this.patchsets$, patchsets =>
+    computeLatestPatchNumWithEdit(patchsets)
+  );
+
   /**
    * Emits the current patchset number. If the route does not define the current
    * patchset num, then this selector waits for the change to be defined and
@@ -203,7 +208,7 @@
       combineLatest([
         this.viewModel.state$,
         this.state$,
-        this.latestPatchNum$,
+        this.latestPatchNumWithEdit$,
       ]).pipe(
         /**
          * If you depend on both, view model and change state, then you want to
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 4715abf..2aa4aa4 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -51,7 +51,7 @@
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Execution, Interaction, Timing} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
+import {fireAlert, fire} from '../../utils/event-util';
 import {Model} from '../model';
 import {define} from '../dependency';
 import {
@@ -716,7 +716,7 @@
         if (result.errorMessage || result.message) {
           fireAlert(document, `${result.message ?? result.errorMessage}`);
         } else {
-          fireEvent(document, 'hide-alert');
+          fire(document, 'hide-alert', {});
         }
         if (result.shouldReload) {
           this.reloadForCheck(run?.checkName);
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 1fdf342..0e78274 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -31,7 +31,7 @@
 import {select} from '../../utils/observable-util';
 import {define} from '../dependency';
 import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
-import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {fire, fireAlert} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
@@ -43,7 +43,6 @@
 import {Model} from '../model';
 import {Deduping} from '../../api/reporting';
 import {extractMentionedUsers, getUserId} from '../../utils/account-util';
-import {EventType} from '../../types/events';
 import {SpecialFilePath} from '../../constants/constants';
 import {AccountsModel} from '../accounts-model/accounts-model';
 import {
@@ -643,7 +642,7 @@
     this.modifyState(s => deleteDraft(s, draft));
     // We don't store empty discarded drafts and don't need an UNDO then.
     if (draft.message?.trim()) {
-      fire(document, EventType.SHOW_ALERT, {
+      fire(document, 'show-alert', {
         message: 'Draft Discarded',
         action: 'Undo',
         callback: () => this.restoreDraft(draft.id),
@@ -693,7 +692,7 @@
 
   private updateRequestToast(requestFailed?: boolean) {
     if (this.numPendingDraftRequests === 0 && !requestFailed) {
-      fireEvent(document, 'hide-alert');
+      fire(document, 'hide-alert', {});
       return;
     }
     const message = getSavingMessage(
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 153777f..d2a0dd8 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -79,6 +79,7 @@
   /** These properties apply to the DIFF child view only. */
   diffView?: {
     path?: string;
+    // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
     lineNum?: number;
     leftSide?: boolean;
   };
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 29e9259..7488e79 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -19,5 +19,4 @@
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  REBASE_CHAIN = 'UiFeature__rebase_chain',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index 480484e..37c2311 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {fire} from '../../utils/event-util';
 import {
   AuthRequestInit,
   AuthService,
@@ -28,14 +29,10 @@
   private _setStatus(status: AuthStatus) {
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
-      document.dispatchEvent(
-        new CustomEvent('auth-error', {
-          detail: {
-            message: Auth.CREDS_EXPIRED_MSG,
-            action: 'Refresh credentials',
-          },
-        })
-      );
+      fire(document, 'auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
     }
     this._status = status;
   }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 107ee16..0503e4c 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -278,6 +278,11 @@
     --robot-comment-background-color: var(--blue-50);
     --unresolved-comment-background-color: #fef7e0;
 
+
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-50);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index a183c86..dc3d4e9 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -138,6 +138,10 @@
     --robot-comment-background-color: #1e3a5f;
     --unresolved-comment-background-color: #614a19;
 
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-tonal);
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index e2612b0..8b02fdf 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -3,59 +3,44 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {FixSuggestionInfo, PatchSetNum} from './common';
+import {AccountInfo, FixSuggestionInfo, PatchSetNum} from './common';
 import {ChangeMessage} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
+import {DropdownLink} from '../elements/shared/gr-dropdown/gr-dropdown';
+import {AutocompleteCommitEvent} from '../elements/shared/gr-autocomplete/gr-autocomplete';
 
-export enum EventType {
-  BIND_VALUE_CHANGED = 'bind-value-changed',
-  CHANGE = 'change',
-  CHANGED = 'changed',
-  CHANGE_MESSAGE_DELETED = 'change-message-deleted',
-  COMMIT = 'commit',
-  DIALOG_CHANGE = 'dialog-change',
-  DROP = 'drop',
-  EDITABLE_CONTENT_SAVE = 'editable-content-save',
-  GR_RPC_LOG = 'gr-rpc-log',
-  IRON_ANNOUNCE = 'iron-announce',
-  KEYDOWN = 'keydown',
-  KEYPRESS = 'keypress',
-  LOCATION_CHANGE = 'location-change',
-  MOVED_LINK_CLICKED = 'moved-link-clicked',
-  NETWORK_ERROR = 'network-error',
-  OPEN_FIX_PREVIEW = 'open-fix-preview',
-  PAGE_ERROR = 'page-error',
-  RELOAD = 'reload',
-  REPLY = 'reply',
-  SERVER_ERROR = 'server-error',
-  SHORTCUT_TRIGGERERD = 'shortcut-triggered',
-  SHOW_ALERT = 'show-alert',
-  SHOW_ERROR = 'show-error',
-  SHOW_TAB = 'show-tab',
-  SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  TAP_ITEM = 'tap-item',
-  TITLE_CHANGE = 'title-change',
-}
-
+// TODO: Local events that are only fired by one component should also be
+// declared and documented in that component. Don't collect ALL the events here.
+// 'show-alert' for example is fine to keep, because it is fired all over the
+// place. But 'line-cursor-moved-in' is only fired by <gr-diff-cursor>, so let's
+// move it there.
 declare global {
   interface HTMLElementEventMap {
-    /* prettier-ignore */
+    'add-reviewer': AddReviewerEvent;
     'bind-value-changed': BindValueChangeEvent;
-    /* prettier-ignore */
+    /** Fired when a 'cancel' button in a dialog was pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<{}>;
+    // prettier-ignore
     'change': ChangeEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'changed': ChangedEvent;
-    'change-message-deleted': ChangeMessageDeletedEvent;
-    /* prettier-ignore */
-    'commit': CommitEvent;
+    // prettier-ignore
+    'close': CustomEvent<{}>;
+    // prettier-ignore
+    'commit': AutocompleteCommitEvent;
+    /** Fired when a 'confirm' button in a dialog was pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<{}>;
     'dialog-change': DialogChangeEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'drop': DropEvent;
-    'editable-content-save': EditableContentSaveEvent;
+    'hide-alert': CustomEvent<{}>;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    'iron-resize': CustomEvent<{}>;
     'line-mouse-enter': LineNumberEvent;
     'line-mouse-leave': LineNumberEvent;
     'line-cursor-moved-in': LineNumberEvent;
@@ -63,10 +48,9 @@
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'reply-to-comment': ReplyToCommentEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'reload': ReloadEvent;
-    /* prettier-ignore */
-    'reply': ReplyEvent;
+    'remove-reviewer': RemoveReviewerEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-tab': SwitchTabEvent;
@@ -81,7 +65,7 @@
     'gr-rpc-log': RpcLogEvent;
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'reload': ReloadEvent;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
@@ -90,6 +74,21 @@
   }
 }
 
+export interface AddAccountEventDetail {
+  value: string;
+}
+export type AddAccountEvent = CustomEvent<AddAccountEventDetail>;
+
+export interface AddReviewerEventDetail {
+  reviewer: AccountInfo;
+}
+export type AddReviewerEvent = CustomEvent<AddReviewerEventDetail>;
+
+export interface RemoveReviewerEventDetail {
+  reviewer: AccountInfo;
+}
+export type RemoveReviewerEvent = CustomEvent<RemoveReviewerEventDetail>;
+
 export interface BindValueChangeEventDetail {
   value: string | undefined;
 }
@@ -97,7 +96,8 @@
 
 export type ChangeEvent = InputEvent;
 
-export type ChangedEvent = CustomEvent<string>;
+// TODO: This event seems to be unused (no listener). Remove?
+export type ChangedEvent = CustomEvent<string | undefined>;
 
 export interface ChangeMessageDeletedEventDetail {
   message: ChangeMessage;
@@ -105,8 +105,6 @@
 export type ChangeMessageDeletedEvent =
   CustomEvent<ChangeMessageDeletedEventDetail>;
 
-export type CommitEvent = CustomEvent;
-
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
 // This informs gr-app-element to remove footer, header from a11y tree
 export interface DialogChangeEventDetail {
@@ -123,6 +121,13 @@
 export type EditableContentSaveEvent =
   CustomEvent<EditableContentSaveEventDetail>;
 
+export interface FileActionTapEventDetail {
+  path: string;
+  action: string;
+}
+
+export type FileActionTapEvent = CustomEvent<FileActionTapEventDetail>;
+
 export interface RpcLogEventDetail {
   status: number | null;
   method: string;
@@ -163,6 +168,7 @@
   userWantsToEdit: boolean;
   unresolved: boolean;
 }
+
 export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
 
 export interface PageErrorEventDetail {
@@ -175,6 +181,11 @@
 }
 export type ReloadEvent = CustomEvent<ReloadEventDetail>;
 
+export interface RemoveAccountEventDetail {
+  account: AccountInfo;
+}
+export type RemoveAccountEvent = CustomEvent<RemoveAccountEventDetail>;
+
 export interface ReplyEventDetail {
   message: ChangeMessage;
 }
@@ -200,6 +211,14 @@
 }
 export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
 
+export interface ShowReplyDialogEventDetail {
+  value: {
+    reviewersOnly: boolean;
+    ccsOnly: boolean;
+  };
+}
+export type ShowReplyDialogEvent = CustomEvent<ShowReplyDialogEventDetail>;
+
 export interface AuthErrorEventDetail {
   message: string;
   action: string;
@@ -231,7 +250,7 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export type TapItemEvent = CustomEvent;
+export type TapItemEvent = CustomEvent<DropdownLink>;
 
 export interface TitleChangeEventDetail {
   title: string;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 752de62..6ac3211 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -62,6 +62,8 @@
   /**
    * Promise that is resolved after the callback is run or the task is
    * cancelled.
+   *
+   * If callback returns a Promise this resolves after the promise is settled.
    */
   public readonly promise: Promise<ResolvedDelayedTaskStatus>;
 
@@ -69,14 +71,28 @@
     value: ResolvedDelayedTaskStatus | PromiseLike<ResolvedDelayedTaskStatus>
   ) => void;
 
-  constructor(private callback: () => void, waitMs = 0) {
+  private callCallbackAndResolveOnCompletion() {
+    let callbackResult;
+    if (this.callback) callbackResult = this.callback();
+    if (callbackResult instanceof Promise) {
+      callbackResult.finally(() => {
+        this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+      });
+    } else {
+      this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+    }
+  }
+
+  constructor(
+    private readonly callback: () => void | Promise<void>,
+    waitMs = 0
+  ) {
     this.promise = new Promise(resolve => {
       this.resolvePromise = resolve;
       this.timerId = window.setTimeout(() => {
         if (this.timerId) _testOnly_allTasks.delete(this.timerId);
         this.timerId = undefined;
-        if (this.callback) this.callback();
-        resolve(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+        this.callCallbackAndResolveOnCompletion();
       }, waitMs);
       _testOnly_allTasks.set(this.timerId, this);
     });
@@ -98,8 +114,7 @@
   flush() {
     if (this.isActive()) {
       this.cancelTimer();
-      if (this.callback) this.callback();
-      this.resolvePromise?.(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+      this.callCallbackAndResolveOnCompletion();
     }
   }
 
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 9f029b8..ee4f73a 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -6,8 +6,8 @@
 import {assert} from '@open-wc/testing';
 import {SinonFakeTimers} from 'sinon';
 import '../test/common-test-setup';
-import {waitEventLoop} from '../test/test-utils';
-import {asyncForeach, debounceP} from './async-util';
+import {mockPromise, waitEventLoop, waitUntil} from '../test/test-utils';
+import {asyncForeach, debounceP, DelayedTask} from './async-util';
 
 suite('async-util tests', () => {
   suite('asyncForeach', () => {
@@ -205,4 +205,16 @@
       await waitEventLoop();
     });
   });
+
+  test('DelayedTask promise resolved when callback is done', async () => {
+    const callbackPromise = mockPromise<void>();
+    const task = new DelayedTask(() => callbackPromise);
+    let completed = false;
+    task.promise.then(() => (completed = true));
+    await waitUntil(() => !task.isActive());
+
+    assert.isFalse(completed);
+    callbackPromise.resolve();
+    await waitUntil(() => completed);
+  });
 });
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index a92f0f8..34a90de 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -523,7 +523,8 @@
   };
 }
 
-export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+export const USER_SUGGESTION_INFO_STRING = 'suggestion';
+export const USER_SUGGESTION_START_PATTERN = `\`\`\`${USER_SUGGESTION_INFO_STRING}\n`;
 
 // This can either mean a user or a checks provided fix.
 // "Provided" means that the fix is sent along with the request
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 49d5382..3704557 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -6,47 +6,34 @@
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
-  EventType,
   SwitchTabEventDetail,
   TabState,
 } from '../types/events';
 
-export function fireEvent(target: EventTarget, type: string) {
-  target.dispatchEvent(
-    new CustomEvent(type, {
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
-
 export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
-  HTMLElementEventMap[K] extends CustomEvent<infer DT>
-    ? unknown extends DT
-      ? never
-      : DT
-    : never;
+  HTMLElementEventMap[K] extends CustomEvent<infer DT> ? DT : never;
 
 type DocumentEventDetailType<K extends keyof DocumentEventMap> =
-  DocumentEventMap[K] extends CustomEvent<infer DT>
-    ? unknown extends DT
-      ? never
-      : DT
-    : never;
+  DocumentEventMap[K] extends CustomEvent<infer DT> ? DT : never;
 
 export function fire<K extends keyof DocumentEventMap>(
-  target: Document,
+  target: Document | undefined,
   type: K,
   detail: DocumentEventDetailType<K>
 ): void;
 
 export function fire<K extends keyof HTMLElementEventMap>(
-  target: EventTarget,
+  target: EventTarget | undefined,
   type: K,
   detail: HTMLElementEventDetailType<K>
 ): void;
 
-export function fire<T>(target: EventTarget, type: string, detail: T) {
+export function fire<T>(
+  target: EventTarget | undefined,
+  type: string,
+  detail: T
+) {
+  if (!target) return;
   target.dispatchEvent(
     new CustomEvent<T>(type, {
       detail,
@@ -56,28 +43,60 @@
   );
 }
 
+export function fireNoBubble<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: true,
+      bubbles: false,
+    })
+  );
+}
+
+export function fireNoBubbleNoCompose<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: false,
+      bubbles: false,
+    })
+  );
+}
+
 export function fireAlert(target: EventTarget, message: string) {
-  fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
+  fire(target, 'show-alert', {message, showDismiss: true});
+}
+
+export function fireError(target: EventTarget, message: string) {
+  fire(target, 'show-error', {message});
 }
 
 export function firePageError(response?: Response | null) {
   if (response === null) response = undefined;
-  fire(document, EventType.PAGE_ERROR, {response});
+  fire(document, 'page-error', {response});
 }
 
 export function fireServerError(response: Response, request?: FetchRequest) {
-  fire(document, EventType.SERVER_ERROR, {
+  fire(document, 'server-error', {
     response,
     request,
   });
 }
 
 export function fireNetworkError(error: Error) {
-  fire(document, EventType.NETWORK_ERROR, {error});
+  fire(document, 'network-error', {error});
 }
 
 export function fireTitleChange(target: EventTarget, title: string) {
-  fire(target, EventType.TITLE_CHANGE, {title});
+  fire(target, 'title-change', {title});
 }
 
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
@@ -86,11 +105,11 @@
   target: EventTarget,
   detail: DialogChangeEventDetail
 ) {
-  fire(target, EventType.DIALOG_CHANGE, detail);
+  fire(target, 'dialog-change', detail);
 }
 
 export function fireIronAnnounce(target: EventTarget, text: string) {
-  fire(target, EventType.IRON_ANNOUNCE, {text});
+  fire(target, 'iron-announce', {text});
 }
 
 export function fireShowTab(
@@ -100,11 +119,11 @@
   tabState?: TabState
 ) {
   const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
-  fire(target, EventType.SHOW_TAB, detail);
+  fire(target, 'show-tab', detail);
 }
 
 export function fireReload(target: EventTarget, clearPatchset?: boolean) {
-  fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+  fire(target, 'reload', {clearPatchset: !!clearPatchset});
 }
 
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index aaa35a4..8929e9c 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -153,7 +153,14 @@
   return false;
 }
 
-export function canVote(label: DetailedLabelInfo, account: AccountInfo) {
+// This method is checking labels.all from change detail,
+// that shows only permitted voting for reviewers or CC.
+// It doesn't have permitted votes for owner. You
+// can see permitted labels for logged in user in change.permitted_labels
+export function canReviewerVote(
+  label: DetailedLabelInfo,
+  account: AccountInfo
+) {
   const approvalInfo = getApprovalInfo(label, account);
   if (!approvalInfo) return false;
   if (approvalInfo.permitted_voting_range) {
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 183671f..7e16ad9 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -265,6 +265,16 @@
   return latest;
 }
 
+// Basically is computeLatestPatchNum but allows "edits".
+export function computeLatestPatchNumWithEdit(
+  allPatchSets?: PatchSet[]
+): RevisionPatchSetNum | undefined {
+  if (!allPatchSets || !allPatchSets.length) {
+    return undefined;
+  }
+  return allPatchSets[0].num;
+}
+
 export function computePredecessor(
   patchset?: PatchSetNum
 ): BasePatchSetNum | undefined {