Merge "Submit requirements: always hide applicability expressions on the API"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index acf65a5..3109ec7 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1169,6 +1169,14 @@
 Result of checking if one change or commit is a pure/clean revert of
 another.
 
+cache `"soy_sauce_compiled_templates"`::
++
+Caches compiled soy templates. Stores at most only one key-value pair with
+a constant key value and the value is a compiled SoySauce templates. The value
+is reloaded automatically every few seconds if there are reads from the cache.
+If cache is not used for 1 minute, the item is removed (i.e. emails can be send
+with templates which are max 1 minute old).
+
 cache `"sshkeys"`::
 +
 Caches unpacked versions of user SSH keys, so the internal SSH daemon
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 0318cd7..d8b6250 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -113,7 +113,7 @@
 * `receivecommits/ps_revision_missing`: errors due to patch set revision missing
 * `receivecommits/push_count`: number of pushes
 ** `kind`:
-   The push kind (direct vs. magic).
+   The push kind (magic, direct or direct_submit).
 ** `project`:
    The name of the project for which the push is done.
 ** `type`:
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index cf6560b..586f685 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -61,7 +61,7 @@
 	Run init before starting the daemon. This will create a new site or
 	upgrade an existing site.
 
---s::
+-s::
 	Start link:dev-inspector.html[Gerrit Inspector] on the console, a
 	built-in interactive inspection environment to assist debugging and
 	troubleshooting of Gerrit code.
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 128bae6..20ad07c 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -11,9 +11,9 @@
 
 Those are the available recipient types:
 
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+* `TO`: The standard To field is used; addresses are visible to all.
+* `CC`: The standard CC field is used; addresses are visible to all.
+* `BCC`: SMTP RCPT TO is used to hide the address.
 
 [[user]]
 == User Level Settings
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index cc8d813..6e6b9d7 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -565,6 +565,11 @@
 cherry-picked locally using the git cherry-pick command and then
 pushed to Gerrit.
 
+[[pure-revert]]
+is:pure-revert::
++
+True if the change is a pure revert.
+
 [[status]]
 status:open, status:pending, status:new::
 +
@@ -772,6 +777,22 @@
 +
 Matches changes with label voted with any score.
 
+`label:Code-Review=+1,count=2`::
++
+Matches changes with exactly two +1 votes to the code-review label. The {MAX,
+MIN, ANY} votes can also be used, for example `label:Code-Review=MAX,count=2` is
+equivalent to `label:Code-Review=2,count=2` (if 2 is the maximum positive vote
+for the code review label). The maximum supported value for `count` is 5.
+`count=0` is not allowed and the query request will fail with `400 Bad Request`.
+
+`label:Code-Review=+1,count>=2`::
++
+Matches changes having two or more +1 votes to the code-review label. Can also
+be used with the {MAX, MIN, ANY} label votes. All operators `>`, `>=`, `<`, `<=`
+are supported.
+Note that a query like `label:Code-Review=+1,count<x` will not match with
+changes having zero +1 votes to this label.
+
 `label:Non-Author-Code-Review=need`::
 +
 Matches changes where the submit rules indicate that a label named
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index e0a72ac4..9a3c388 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -34,15 +35,18 @@
   private final AbandonOp.Factory abandonOpFactory;
   private final ChangeCleanupConfig cfg;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   BatchAbandon(
       AbandonOp.Factory abandonOpFactory,
       ChangeCleanupConfig cfg,
-      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.abandonOpFactory = abandonOpFactory;
     this.cfg = cfg;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   /**
@@ -74,6 +78,7 @@
                   change.project().get(), project.get()));
         }
         u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
+        u.addOp(change.getId(), storeSubmitRequirementsOpFactory.create());
       }
       u.execute();
 
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 6c25bae..24882cb 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -168,9 +168,8 @@
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.MailSoySauceProvider;
+import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
-import com.google.gerrit.server.mail.send.MailTemplates;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -227,7 +226,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.multibindings.OptionalBinder;
-import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
@@ -289,6 +287,7 @@
     install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
     install(new ApprovalModule());
+    install(new MailSoySauceModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
@@ -330,7 +329,6 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(EnablePeerIPInReflogRecord.class)
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 66e7d80..f4c7a92 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -747,14 +747,19 @@
       return;
     }
 
-    if (!magicCommands.isEmpty()) {
-      metrics.pushCount.increment("magic", project.getName(), getUpdateType(magicCommands));
-    }
-    if (!regularCommands.isEmpty()) {
-      metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-    }
-
     try {
+      if (!magicCommands.isEmpty()) {
+        parseMagicBranch(Iterables.getLast(magicCommands));
+        // Using the submit option submits the created change(s) immediately without checking labels
+        // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
+        // code review is being done.
+        String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
+        metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
+      }
+      if (!regularCommands.isEmpty()) {
+        metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+      }
+
       if (!regularCommands.isEmpty()) {
         handleRegularCommands(regularCommands, progress);
         return;
@@ -763,7 +768,6 @@
       boolean first = true;
       for (ReceiveCommand cmd : magicCommands) {
         if (first) {
-          parseMagicBranch(cmd);
           first = false;
         } else {
           reject(cmd, "duplicate request");
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index df1888b..e50482d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -21,7 +21,7 @@
 
   @VisibleForTesting
   public static final String ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP =
-      "only users with Toogle-Wip-State permission can modify Work-in-Progress";
+      "only users with Toggle-Wip-State permission can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Contact an administrator to fix the permissions";
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index b9569e4..2cdb7c8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -33,6 +33,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -412,6 +413,10 @@
       integer(ChangeQueryBuilder.FIELD_REVERTOF)
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
+  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
+      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+          .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -612,47 +617,107 @@
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
+    Table<String, Short, Integer> voteCounts = HashBasedTable.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        increment(voteCounts, a.label(), a.value());
         Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
-        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
-        if (cd.change().getOwner().equals(a.accountId())) {
-          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-        }
-        if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
-          allApprovals.add(
-              formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-        }
+
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        allApprovals.addAll(getMagicLabelFormats(a.label(), a.value(), labelType, a.accountId()));
+        allApprovals.addAll(getLabelOwnerFormats(a, cd, labelType));
+        allApprovals.addAll(getLabelNonUploaderFormats(a, cd, labelType));
         distinctApprovals.add(formatLabel(a.label(), a.value()));
-        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
+        distinctApprovals.addAll(
+            getMagicLabelFormats(a.label(), a.value(), labelType, /* accountId= */ null));
       }
     }
     allApprovals.addAll(distinctApprovals);
+    allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
     return allApprovals;
   }
 
-  private static List<String> getMaxMinAnyLabels(
+  private static void increment(Table<String, Short, Integer> table, String k1, short k2) {
+    if (!table.contains(k1, k2)) {
+      table.put(k1, k2, 1);
+    } else {
+      int val = table.get(k1, k2);
+      table.put(k1, k2, val + 1);
+    }
+  }
+
+  private static List<String> getCountLabelFormats(
+      Table<String, Short, Integer> voteCounts, ChangeData cd) {
+    List<String> allFormats = new ArrayList<>();
+    for (String label : voteCounts.rowMap().keySet()) {
+      Optional<LabelType> labelType = cd.getLabelTypes().byLabel(label);
+      Map<Short, Integer> row = voteCounts.row(label);
+      for (short vote : row.keySet()) {
+        int count = row.get(vote);
+        allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
+      }
+    }
+    return allFormats;
+  }
+
+  private static List<String> getCountLabelFormats(
+      Optional<LabelType> labelType, String label, short vote, int count) {
+    List<String> formats =
+        getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
+    formats.add(formatLabel(label, vote, count));
+    return formats;
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
       String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
+      String label,
+      short labelVal,
+      Optional<LabelType> labelType,
+      @Nullable Account.Id accountId,
+      @Nullable Integer count) {
     List<String> labels = new ArrayList<>();
     if (labelType.isPresent()) {
       if (labelVal == labelType.get().getMaxPositive()) {
-        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
       }
       if (labelVal == labelType.get().getMaxNegative()) {
-        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
       }
     }
-    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
     return labels;
   }
 
+  private static List<String> getLabelOwnerFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (cd.change().getOwner().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
+  private static List<String> getLabelNonUploaderFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -727,25 +792,33 @@
                       decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
+    return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
+  }
+
+  public static String formatLabel(String label, int value, @Nullable Integer count) {
+    return formatLabel(label, value, /* accountId= */ null, count);
   }
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
+    return formatLabel(label, value, accountId, /* count= */ null);
+  }
+
+  public static String formatLabel(
+      String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + (value >= 0 ? "+" : "")
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
-  public static String formatLabel(String label, String value) {
-    return formatLabel(label, value, null);
-  }
-
-  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+  public static String formatLabel(
+      String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + "="
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
   private static String formatAccount(Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9339d62..ee93065 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -183,9 +183,18 @@
       new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
 
   /** Added new field {@link ChangeField#UPLOADER}. */
+  @Deprecated
   static final Schema<ChangeData> V71 =
       new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
 
+  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
+  @Deprecated
+  static final Schema<ChangeData> V72 =
+      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
+
+  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
+  static final Schema<ChangeData> V73 = schema(V72, false);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
similarity index 94%
rename from java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
rename to java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index aade30f..ad1703d 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
@@ -31,9 +30,13 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 
-/** Configures Soy Sauce object for rendering email templates. */
+/**
+ * Configures and loads Soy Sauce object for rendering email templates.
+ *
+ * <p>It reloads templates each time when {@link #load()} is called.
+ */
 @Singleton
-public class MailSoySauceProvider implements Provider<SoySauce> {
+class MailSoySauceLoader {
 
   // Note: will fail to construct the tofu object if this array is empty.
   private static final String[] TEMPLATES = {
@@ -90,7 +93,7 @@
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoySauceProvider(
+  MailSoySauceLoader(
       SitePaths site,
       SoyAstCache cache,
       PluginSetContext<MailSoyTemplateProvider> templateProviders) {
@@ -99,8 +102,7 @@
     this.templateProviders = templateProviders;
   }
 
-  @Override
-  public SoySauce get() throws ProvisionException {
+  public SoySauce load() {
     SoyFileSet.Builder builder = SoyFileSet.builder();
     builder.setSoyAstCache(cache);
     for (String name : TEMPLATES) {
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
new file mode 100644
index 0000000..a3cf3e3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -0,0 +1,89 @@
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import javax.inject.Provider;
+
+/**
+ * Provides support for soy templates
+ *
+ * <p>Module loads templates with {@link MailSoySauceLoader} and caches compiled templates. The
+ * cache refreshes automatically, so Gerrit does not need to be restarted if templates are changed.
+ */
+public class MailSoySauceModule extends CacheModule {
+  static final String CACHE_NAME = "soy_sauce_compiled_templates";
+  private static final String SOY_LOADING_CACHE_KEY = "KEY";
+
+  @Override
+  protected void configure() {
+    // Cache stores only a single key-value pair (key is SOY_LOADING_CACHE_KEY). We are using
+    // cache only for it refresh/expire logic.
+    cache(CACHE_NAME, String.class, SoySauce.class)
+        // Cache refreshes a value only on the access (if refreshAfterWrite interval is
+        // passed). While the value is refreshed, cache returns old value.
+        // Adding expireAfterWrite interval prevents cache from returning very old template.
+        .refreshAfterWrite(Duration.ofSeconds(5))
+        .expireAfterWrite(Duration.ofMinutes(1))
+        .loader(SoySauceCacheLoader.class);
+    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(SoySauceProvider.class);
+  }
+
+  @Singleton
+  static class SoySauceProvider implements Provider<SoySauce> {
+    private final LoadingCache<String, SoySauce> templateCache;
+
+    @Inject
+    SoySauceProvider(@Named(CACHE_NAME) LoadingCache<String, SoySauce> templateCache) {
+      this.templateCache = templateCache;
+    }
+
+    @Override
+    public SoySauce get() {
+      try {
+        return templateCache.get(SOY_LOADING_CACHE_KEY);
+      } catch (ExecutionException e) {
+        throw new ProvisionException("Can't get SoySauce from the cache", e);
+      }
+    }
+  }
+
+  @Singleton
+  static class SoySauceCacheLoader extends CacheLoader<String, SoySauce> {
+    private final ListeningExecutorService executor;
+    private final MailSoySauceLoader loader;
+
+    @Inject
+    SoySauceCacheLoader(
+        @CacheRefreshExecutor ListeningExecutorService executor, MailSoySauceLoader loader) {
+      this.executor = executor;
+      this.loader = loader;
+    }
+
+    @Override
+    public SoySauce load(String key) throws Exception {
+      checkArgument(
+          SOY_LOADING_CACHE_KEY.equals(key),
+          "Cache can have only one element with a key '%s'",
+          SOY_LOADING_CACHE_KEY);
+      return loader.load();
+    }
+
+    @Override
+    public ListenableFuture<SoySauce> reload(String key, SoySauce soySauce) {
+      return executor.submit(() -> loader.load());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 3afdcdd..7e50c6f 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -342,4 +342,12 @@
   public static Predicate<ChangeData> submitRuleStatus(String value) {
     return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
   }
+
+  /**
+   * Returns a predicate that matches with changes that are pure reverts if {@code value} is equal
+   * to "1", or non-pure reverts if {@code value} is "0".
+   */
+  public static Predicate<ChangeData> pureRevert(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 57191c5..d435df1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaUtil;
@@ -83,6 +84,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.PredicateArgs.ValOp;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import com.google.inject.Inject;
@@ -203,6 +205,7 @@
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
+  public static final String FIELD_PURE_REVERT = "ispurerevert";
   public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
@@ -212,6 +215,7 @@
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
 
@@ -521,22 +525,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -626,10 +622,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'has:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -672,20 +665,13 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-        throw new QueryParseException(
-            "'is:uploader' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return Predicate.and(
-            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
-            ReviewerPredicate.reviewer(self()));
-      }
-      return ReviewerPredicate.reviewer(self());
+      return Predicate.and(
+          Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -700,25 +686,16 @@
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.MERGE)) {
-        return new BooleanPredicate(ChangeField.MERGE);
-      }
-      throw new QueryParseException("'is:merge' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.MERGE, "is:merge");
+      return new BooleanPredicate(ChangeField.MERGE);
     }
 
     if ("private".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
-        return new BooleanPredicate(ChangeField.PRIVATE);
-      }
-      throw new QueryParseException(
-          "'is:private' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.PRIVATE);
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'is:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -730,6 +707,11 @@
       return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
+    if ("pure-revert".equalsIgnoreCase(value)) {
+      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      return ChangePredicates.pureRevert("1");
+    }
+
     if ("submittable".equalsIgnoreCase(value)) {
       // SubmittablePredicate will match if *any* of the submit records are OK,
       // but we need to check that they're *all* OK, so check that none of the
@@ -747,26 +729,17 @@
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.STARTED)) {
-        return new BooleanPredicate(ChangeField.STARTED);
-      }
-      throw new QueryParseException(
-          "'is:started' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.STARTED, "is:started");
+      return new BooleanPredicate(ChangeField.STARTED);
     }
 
     if ("wip".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return new BooleanPredicate(ChangeField.WIP);
-      }
-      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.WIP);
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.CHERRY_PICK)) {
-        return new BooleanPredicate(ChangeField.CHERRY_PICK);
-      }
-      throw new QueryParseException(
-          "'is:cherrypick' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
+      return new BooleanPredicate(ChangeField.CHERRY_PICK);
     }
 
     // for plugins the value will be operandName_pluginName
@@ -883,10 +856,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
-      throw new QueryParseException(
-          "'inhashtag' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
@@ -942,10 +912,7 @@
 
   @Operator
   public Predicate<ChangeData> extension(String ext) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXTENSION)) {
-      return new FileExtensionPredicate(ext);
-    }
-    throw new QueryParseException("'extension' operator is not supported by change index version");
+    return new FileExtensionPredicate(ext);
   }
 
   @Operator
@@ -955,19 +922,12 @@
 
   @Operator
   public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
-      return new FileExtensionListPredicate(extList);
-    }
-    throw new QueryParseException(
-        "'onlyextensions' operator is not supported by change index version");
+    return new FileExtensionListPredicate(extList);
   }
 
   @Operator
   public Predicate<ChangeData> footer(String footer) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return ChangePredicates.footer(footer);
-    }
-    throw new QueryParseException("'footer' operator is not supported by change index version");
+    return ChangePredicates.footer(footer);
   }
 
   @Operator
@@ -977,13 +937,10 @@
 
   @Operator
   public Predicate<ChangeData> directory(String directory) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.DIRECTORY)) {
-      if (directory.startsWith("^")) {
-        return new RegexDirectoryPredicate(directory);
-      }
-      return ChangePredicates.directory(directory);
+    if (directory.startsWith("^")) {
+      return new RegexDirectoryPredicate(directory);
     }
-    throw new QueryParseException("'directory' operator is not supported by change index version");
+    return ChangePredicates.directory(directory);
   }
 
   @Operator
@@ -991,6 +948,8 @@
       throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
+    Integer count = null;
+    PredicateArgs.Operator countOp = null;
 
     // Parse for:
     // label:Code-Review=1,user=jsmith or
@@ -1001,6 +960,7 @@
     // Special case: votes by owners can be tracked with ",owner":
     // label:Code-Review+2,owner
     // label:Code-Review+2,user=owner
+    // label:Code-Review+1,count=2
     List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
     name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
@@ -1008,17 +968,40 @@
       // process the user/group piece
       PredicateArgs lblArgs = new PredicateArgs(splitReviewer.get(1));
 
-      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
-        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          if (pair.getValue().equals(ARG_ID_OWNER)) {
+      // Disallow using the "count=" arg in conjunction with the "user=" or "group=" args. to avoid
+      // unnecessary complexity.
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_USER);
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_GROUP);
+
+      for (Map.Entry<String, ValOp> pair : lblArgs.keyValue.entrySet()) {
+        String key = pair.getKey();
+        String value = pair.getValue().value();
+        PredicateArgs.Operator operator = pair.getValue().operator();
+        if (key.equalsIgnoreCase(ARG_ID_USER)) {
+          if (value.equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (pair.getValue().equals(ARG_ID_NON_UPLOADER)) {
+          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(pair.getValue());
+            accounts = parseAccount(value);
           }
-        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
-          group = parseGroup(pair.getValue()).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
+          group = parseGroup(value).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_COUNT)) {
+          if (!isInt(value)) {
+            throw new QueryParseException("Invalid count argument. Value should be an integer");
+          }
+          count = Integer.parseInt(value);
+          countOp = operator;
+          if (count == 0) {
+            throw new QueryParseException("Argument count=0 is not allowed.");
+          }
+          if (count > LabelPredicate.MAX_COUNT) {
+            throw new QueryParseException(
+                String.format(
+                    "count=%d is not allowed. Maximum allowed value for count is %d.",
+                    count, LabelPredicate.MAX_COUNT));
+          }
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
@@ -1055,7 +1038,7 @@
     // If the vote piece looks like Code-Review=NEED with a valid non-numeric
     // submit record status, interpret as a submit record query.
     int eq = name.indexOf('=');
-    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+    if (eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
       if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
@@ -1067,7 +1050,18 @@
       }
     }
 
-    return new LabelPredicate(args, name, accounts, group);
+    return new LabelPredicate(args, name, accounts, group, count, countOp);
+  }
+
+  /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
+  private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
+      throws QueryParseException {
+    Map<String, ValOp> keyValArgs = labelArgs.keyValue;
+    if (keyValArgs.containsKey(k1) && keyValArgs.containsKey(k2)) {
+      throw new QueryParseException(
+          String.format(
+              "Cannot use the '%s' argument in conjunction with the '%s' argument", k1, k2));
+    }
   }
 
   private static boolean isInt(String s) {
@@ -1197,9 +1191,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1214,10 +1206,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-      throw new QueryParseException(
-          "'attention' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1262,9 +1251,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1309,10 +1296,7 @@
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
       return Predicate.any();
     }
-    if (args.getSchema().hasField(ChangeField.WIP)) {
-      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
-    }
-    return byState;
+    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
   }
 
   @Operator
@@ -1400,7 +1384,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1409,7 +1393,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1451,7 +1435,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1460,7 +1444,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1488,20 +1472,14 @@
 
   @Operator
   public Predicate<ChangeData> author(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
   }
 
   @Operator
@@ -1524,41 +1502,31 @@
     if (value == null || Ints.tryParse(value) == null) {
       throw new QueryParseException("'revertof' must be an integer");
     }
-    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
-    }
-    throw new QueryParseException("'revertof' operator is not supported by change index version");
+    return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
   }
 
   @Operator
   public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return ChangePredicates.submissionId(value);
-    }
-    throw new QueryParseException(
-        "'submissionid' operator is not supported by change index version");
+    return ChangePredicates.submissionId(value);
   }
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
-        && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
-      if (Ints.tryParse(value) != null) {
-        return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
-      }
-      try {
-        PatchSet.Id patchSetId = PatchSet.Id.parse(value);
-        return ChangePredicates.cherryPickOf(patchSetId);
-      } catch (IllegalArgumentException e) {
-        throw new QueryParseException(
-            "'"
-                + value
-                + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
-            e);
-      }
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    if (Ints.tryParse(value) != null) {
+      return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
-    throw new QueryParseException(
-        "'cherrypickof' operator is not supported by change index version");
+    try {
+      PatchSet.Id patchSetId = PatchSet.Id.parse(value);
+      return ChangePredicates.cherryPickOf(patchSetId);
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(
+          "'"
+              + value
+              + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
+          e);
+    }
   }
 
   @Override
@@ -1617,6 +1585,14 @@
     return Predicate.or(predicates);
   }
 
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    if (!args.index.getSchema().hasField(field)) {
+      throw new QueryParseException(
+          String.format("'%s' operator is not supported by change index version", operator));
+    }
+  }
+
   private Predicate<ChangeData> getAuthorOrCommitterPredicate(
       String who,
       Function<String, Predicate<ChangeData>> exactPredicateFunc,
@@ -1731,11 +1707,9 @@
       String who, ReviewerStateInternal state, boolean forDefaultField)
       throws QueryParseException, IOException, ConfigInvalidException {
     Predicate<ChangeData> reviewerByEmailPredicate = null;
-    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
-      Address address = Address.tryParse(who);
-      if (address != null) {
-        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
-      }
+    Address address = Address.tryParse(who);
+    if (address != null) {
+      reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
     }
 
     Predicate<ChangeData> reviewerPredicate = null;
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 12efecb..b2bc6aa 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -34,17 +35,34 @@
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
+  /** label name to be matched. */
   protected final String label;
+
+  /** Expected vote value for the label. */
   protected final int expVal;
+
+  /**
+   * Number of times the value {@link #expVal} for label {@link #label} should occur. If null, match
+   * with any count greater or equal to 1.
+   */
+  @Nullable protected final Integer count;
+
+  /** Account ID that has voted on the label. */
   protected final Account.Id account;
+
   protected final AccountGroup.UUID group;
 
   public EqualsLabelPredicate(
-      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+      LabelPredicate.Args args,
+      String label,
+      int expVal,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
     this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
+    this.count = count;
     this.group = args.group;
     this.label = label;
     this.expVal = expVal;
@@ -60,6 +78,14 @@
       return false;
     }
 
+    if (Integer.valueOf(0).equals(count)) {
+      // We don't match against count=0 so that the computation is identical to the stored values
+      // in the index. We do that since computing count=0 requires looping on all {label_type,
+      // vote_value} for the change and storing a {count=0} format for it in the change index which
+      // is computationally expensive.
+      return false;
+    }
+
     Optional<ProjectState> project = projectCache.get(c.getDest().project());
     if (!project.isPresent()) {
       // The project has disappeared.
@@ -73,12 +99,13 @@
     }
 
     boolean hasVote = false;
+    int matchingVotes = 0;
     object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
         if (match(object, p.value(), p.accountId())) {
-          return true;
+          matchingVotes += 1;
         }
       }
     }
@@ -87,7 +114,7 @@
       return true;
     }
 
-    return false;
+    return count == null ? matchingVotes >= 1 : matchingVotes == count;
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 5f017fb..2e09075 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.OrPredicate;
@@ -29,9 +29,11 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.IntStream;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
   protected static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
     protected final ProjectCache projectCache;
@@ -40,6 +42,8 @@
     protected final String value;
     protected final Set<Account.Id> accounts;
     protected final AccountGroup.UUID group;
+    protected final Integer count;
+    protected final PredicateArgs.Operator countOp;
 
     protected Args(
         ProjectCache projectCache,
@@ -47,13 +51,17 @@
         IdentifiedUser.GenericFactory userFactory,
         String value,
         Set<Account.Id> accounts,
-        AccountGroup.UUID group) {
+        AccountGroup.UUID group,
+        @Nullable Integer count,
+        @Nullable PredicateArgs.Operator countOp) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
       this.value = value;
       this.accounts = accounts;
       this.group = group;
+      this.count = count;
+      this.countOp = countOp;
     }
   }
 
@@ -75,19 +83,35 @@
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
-      AccountGroup.UUID group) {
+      AccountGroup.UUID group,
+      @Nullable Integer count,
+      @Nullable PredicateArgs.Operator countOp) {
     super(
         predicates(
-            new Args(a.projectCache, a.permissionBackend, a.userFactory, value, accounts, group)));
+            new Args(
+                a.projectCache,
+                a.permissionBackend,
+                a.userFactory,
+                value,
+                accounts,
+                group,
+                count,
+                countOp)));
     this.value = value;
   }
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
-
+    List<Integer> counts = getCounts(args.count, args.countOp);
     try {
       MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
-      return ImmutableList.of(magicLabelPredicate(args, mlv));
+      List<Predicate<ChangeData>> result = Lists.newArrayListWithCapacity(counts.size());
+      if (counts.isEmpty()) {
+        result.add(magicLabelPredicate(args, mlv, /* count= */ null));
+      } else {
+        counts.forEach(count -> result.add(magicLabelPredicate(args, mlv, count)));
+      }
+      return result;
     } catch (IllegalArgumentException e) {
       // Try next format.
     }
@@ -123,16 +147,24 @@
     int min = range.min;
     int max = range.max;
 
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
+    List<Predicate<ChangeData>> r =
+        Lists.newArrayListWithCapacity((counts.isEmpty() ? 1 : counts.size()) * (max - min + 1));
     for (int i = min; i <= max; i++) {
-      r.add(onePredicate(args, prefix, i));
+      if (counts.isEmpty()) {
+        r.add(onePredicate(args, prefix, i, /* count= */ null));
+      } else {
+        for (int count : counts) {
+          r.add(onePredicate(args, prefix, i, count));
+        }
+      }
     }
     return r;
   }
 
-  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (expVal != 0) {
-      return equalsLabelPredicate(args, label, expVal);
+      return equalsLabelPredicate(args, label, expVal, count);
     }
     return noLabelQuery(args, label);
   }
@@ -140,34 +172,66 @@
   protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
-      r.add(equalsLabelPredicate(args, label, i));
-      r.add(equalsLabelPredicate(args, label, -i));
+      r.add(equalsLabelPredicate(args, label, i, /* count= */ null));
+      r.add(equalsLabelPredicate(args, label, -i, /* count= */ null));
     }
     return not(or(r));
   }
 
-  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new EqualsLabelPredicate(args, label, expVal, null);
+      return new EqualsLabelPredicate(args, label, expVal, null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new EqualsLabelPredicate(args, label, expVal, a));
+      r.add(new EqualsLabelPredicate(args, label, expVal, a, count));
     }
     return or(r);
   }
 
-  protected static Predicate<ChangeData> magicLabelPredicate(Args args, MagicLabelVote mlv) {
+  protected static Predicate<ChangeData> magicLabelPredicate(
+      Args args, MagicLabelVote mlv, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new MagicLabelPredicate(args, mlv, /* account= */ null);
+      return new MagicLabelPredicate(args, mlv, /* account= */ null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new MagicLabelPredicate(args, mlv, a));
+      r.add(new MagicLabelPredicate(args, mlv, a, count));
     }
     return or(r);
   }
 
+  private static List<Integer> getCounts(
+      @Nullable Integer count, @Nullable PredicateArgs.Operator countOp) {
+    List<Integer> result = new ArrayList<>();
+    if (count == null) {
+      return result;
+    }
+    switch (countOp) {
+      case EQUAL:
+      case GREATER_EQUAL:
+      case LESS_EQUAL:
+        result.add(count);
+        break;
+      default:
+        break;
+    }
+    switch (countOp) {
+      case GREATER:
+      case GREATER_EQUAL:
+        IntStream.range(count + 1, MAX_COUNT + 1).forEach(result::add);
+        break;
+      case LESS:
+      case LESS_EQUAL:
+        IntStream.range(0, count).forEach(result::add);
+        break;
+      default:
+        break;
+    }
+    return result;
+  }
+
   @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index 3917c79..5a81ca1 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -30,13 +31,21 @@
   protected final LabelPredicate.Args args;
   private final MagicLabelVote magicLabelVote;
   private final Account.Id account;
+  @Nullable private final Integer count;
 
   public MagicLabelPredicate(
-      LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
-    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+      LabelPredicate.Args args,
+      MagicLabelVote magicLabelVote,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(
+        ChangeField.LABEL,
+        ChangeField.formatLabel(
+            magicLabelVote.label(), magicLabelVote.value().name(), account, count));
     this.account = account;
     this.args = args;
     this.magicLabelVote = magicLabelVote;
+    this.count = count;
   }
 
   @Override
@@ -87,7 +96,7 @@
   }
 
   private EqualsLabelPredicate numericPredicate(String label, short value) {
-    return new EqualsLabelPredicate(args, label, value, account);
+    return new EqualsLabelPredicate(args, label, value, account, count);
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index d82b9bc..9f0dffb 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * This class is used to extract comma separated values in a predicate.
@@ -30,8 +33,35 @@
  * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3).
  */
 public class PredicateArgs {
+  private static final Pattern SPLIT_PATTERN = Pattern.compile("(>|>=|=|<|<=)([^=].*)$");
+
   public List<String> positional;
-  public Map<String, String> keyValue;
+  public Map<String, ValOp> keyValue;
+
+  enum Operator {
+    EQUAL("="),
+    GREATER_EQUAL(">="),
+    GREATER(">"),
+    LESS_EQUAL("<="),
+    LESS("<");
+
+    final String op;
+
+    Operator(String op) {
+      this.op = op;
+    }
+  };
+
+  @AutoValue
+  public abstract static class ValOp {
+    abstract String value();
+
+    abstract Operator operator();
+
+    static ValOp create(String value, Operator operator) {
+      return new AutoValue_PredicateArgs_ValOp(value, operator);
+    }
+  }
 
   /**
    * Parses query arguments into {@link #keyValue} and/or {@link #positional}..
@@ -46,19 +76,39 @@
     keyValue = new HashMap<>();
 
     for (String arg : Splitter.on(',').split(args)) {
-      List<String> splitKeyValue = Splitter.on('=').splitToList(arg);
+      Matcher m = SPLIT_PATTERN.matcher(arg);
 
-      if (splitKeyValue.size() == 1) {
-        positional.add(splitKeyValue.get(0));
-      } else if (splitKeyValue.size() == 2) {
-        if (!keyValue.containsKey(splitKeyValue.get(0))) {
-          keyValue.put(splitKeyValue.get(0), splitKeyValue.get(1));
+      if (!m.find()) {
+        positional.add(arg);
+      } else if (m.groupCount() == 2) {
+        String key = arg.substring(0, m.start());
+        String op = m.group(1);
+        String val = m.group(2);
+        if (!keyValue.containsKey(key)) {
+          keyValue.put(key, ValOp.create(val, getOperator(op)));
         } else {
-          throw new QueryParseException("Duplicate key " + splitKeyValue.get(0));
+          throw new QueryParseException("Duplicate key " + key);
         }
       } else {
-        throw new QueryParseException("invalid arg " + arg);
+        throw new QueryParseException("Invalid arg " + arg);
       }
     }
   }
+
+  private Operator getOperator(String operator) {
+    switch (operator) {
+      case "<":
+        return Operator.LESS;
+      case "<=":
+        return Operator.LESS_EQUAL;
+      case "=":
+        return Operator.EQUAL;
+      case ">=":
+        return Operator.GREATER_EQUAL;
+      case ">":
+        return Operator.GREATER;
+      default:
+        throw new IllegalArgumentException("Invalid Operator " + operator);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index e63714f..cd0fee3 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.inject.Inject;
 
@@ -32,4 +33,10 @@
   SubmitRequirementChangeQueryBuilder(Arguments args) {
     super(def, args);
   }
+
+  @Override
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+    // Submit requirements don't rely on the index, so they can be used regardless of index schema
+    // version.
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 2cfc3f5..affe947 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -48,6 +49,7 @@
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyResolver notifyResolver;
   private final PatchSetUtil patchSetUtil;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   Abandon(
@@ -55,12 +57,14 @@
       ChangeJson.Factory json,
       AbandonOp.Factory abandonOpFactory,
       NotifyResolver notifyResolver,
-      PatchSetUtil patchSetUtil) {
+      PatchSetUtil patchSetUtil,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.updateFactory = updateFactory;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyResolver = notifyResolver;
     this.patchSetUtil = patchSetUtil;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   @Override
@@ -119,7 +123,9 @@
     AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
     try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.nowTs())) {
       u.setNotify(notify);
-      u.addOp(notes.getChangeId(), op).execute();
+      u.addOp(notes.getChangeId(), op);
+      u.addOp(notes.getChangeId(), storeSubmitRequirementsOpFactory.create());
+      u.execute();
     }
     return op.getChange();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 50098d7..58c222a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4644,6 +4644,131 @@
         ExperimentFeaturesConstants
             .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
       })
+  public void submitRequirement_storedForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=+2"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(r.getChangeId()).abandon();
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().expression().expressionString())
+          .isEqualTo("label:Code-Review=+2");
+    }
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
+  public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=+2"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+
+      // Add another submit requirement. This will not get returned for the abandoned change, since
+      // we return the state of the SR results when the change was abandoned.
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("New-Requirement")
+              .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=+2");
+
+      // Restore the change, the new requirement will show up
+      gApi.changes().id(changeId).restore();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=+2");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+
+      // Abandon again, make sure the new requirement was persisted
+      gApi.changes().id(changeId).abandon();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=+2");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+    }
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
   public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
     configSubmitRequirement(
         project,
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index f511683..b76d5cb 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -32,6 +33,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -215,6 +217,33 @@
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
+  @Test
+  public void byPureRevert() throws Exception {
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "is:pure-revert",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).revert().get();
+    String revertId = Integer.toString(changeInfo._number);
+    ChangeData revertChangeData =
+        changeQueryProvider.get().byLegacyChangeId(Change.Id.parse(revertId)).get(0);
+    result = evaluator.evaluateRequirement(sr, revertChangeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 0527a91..689698e 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -54,6 +54,7 @@
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
+        "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/cancellation",
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
similarity index 89%
rename from javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
rename to javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index 2ec5e4d..fbeabe1 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -25,7 +25,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class MailSoySauceProviderTest {
+public class MailSoySauceLoaderTest {
 
   private SitePaths sitePaths;
   private DynamicSet<MailSoyTemplateProvider> set;
@@ -38,11 +38,11 @@
 
   @Test
   public void soyCompilation() {
-    MailSoySauceProvider provider =
-        new MailSoySauceProvider(
+    MailSoySauceLoader loader =
+        new MailSoySauceLoader(
             sitePaths,
             new SoyAstCache(),
             new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
-    assertThat(provider.get()).isNotNull(); // should not throw
+    assertThat(loader.load()).isNotNull(); // should not throw
   }
 }
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
new file mode 100644
index 0000000..bb443f8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -0,0 +1,60 @@
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Names;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.nio.file.Paths;
+import javax.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class MailSoySauceModuleTest {
+  @Test
+  public void soySauceProviderReturnsCachedValue() throws Exception {
+    SitePaths sitePaths = new SitePaths(Paths.get("."));
+    Injector injector =
+        Guice.createInjector(
+            new MailSoySauceModule(),
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                super.configure();
+                bind(ListeningExecutorService.class)
+                    .annotatedWith(CacheRefreshExecutor.class)
+                    .toInstance(newDirectExecutorService());
+                bind(SitePaths.class).toInstance(sitePaths);
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(new Config());
+                bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                install(new DefaultMemoryCacheModule());
+              }
+            });
+    Provider<SoySauce> soySauceProvider =
+        injector.getProvider(Key.get(SoySauce.class, MailTemplates.class));
+    LoadingCache<String, SoySauce> cache =
+        injector.getInstance(
+            Key.get(
+                new TypeLiteral<LoadingCache<String, SoySauce>>() {},
+                Names.named(MailSoySauceModule.CACHE_NAME)));
+    assertThat(cache.stats().loadCount()).isEqualTo(0);
+    // Theoretically, this can be flaky, if the delay before the second get takes several seconds.
+    // We assume that tests is fast enough.
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(cache.stats().loadCount()).isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3b671aa..5253a5b 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -40,6 +41,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -48,6 +52,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -141,7 +146,6 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
@@ -1038,6 +1042,7 @@
     ChangeInserter ins3 = newChange(repo);
     ChangeInserter ins4 = newChange(repo);
     ChangeInserter ins5 = newChange(repo);
+    ChangeInserter ins6 = newChange(repo);
 
     Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
@@ -1050,7 +1055,13 @@
     Change reviewPlus1Change = insert(repo, ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewPlus2Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert(repo, ins5);
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(createAccount("user1")));
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(userId));
+
+    Change reviewPlus2Change = insert(repo, ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1061,8 +1072,10 @@
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
-    Map<Integer, Change> changes = new LinkedHashMap<>(5);
+    Multimap<Integer, Change> changes =
+        Multimaps.newListMultimap(Maps.newLinkedHashMap(), () -> Lists.newArrayList());
     changes.put(2, reviewPlus2Change);
+    changes.put(1, reviewTwoPlus1Change);
     changes.put(1, reviewPlus1Change);
     changes.put(0, noLabelChange);
     changes.put(-1, reviewMinus1Change);
@@ -1074,9 +1087,9 @@
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
     assertQuery("label:Code-Review-1", reviewMinus1Change);
     assertQuery("label:Code-Review=0", noLabelChange);
-    assertQuery("label:Code-Review=+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=1", reviewPlus1Change);
-    assertQuery("label:Code-Review+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review+1", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
@@ -1084,6 +1097,7 @@
     assertQuery(
         "label:Code-Review=ANY",
         reviewPlus2Change,
+        reviewTwoPlus1Change,
         reviewPlus1Change,
         reviewMinus1Change,
         reviewMinus2Change);
@@ -1112,14 +1126,70 @@
     assertQuery("label:Code-Review<-2");
 
     assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
     assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+
+    // count=0 is not allowed
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=+2,count=0"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Argument count=0 is not allowed.");
+    assertQuery("label:Code-Review=1,count=1", reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>1", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=3");
+    thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=1,count=7"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("count=7 is not allowed. Maximum allowed value for count is 5.");
+
+    // Less than operator does not match with changes having count=0 for a specific vote value (i.e.
+    // no votes for that specific value). We do that deliberately since the computation of count=0
+    // for label values is expensive when the change is re-indexed. This is because the operation
+    // requires generating all formats for all {label-type, vote}=0 values that are non-voted for
+    // the change and storing them with the 'count=0' format.
+    assertQuery("label:Code-Review=1,count<5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=1", // reviewTwoPlus1Change is not matched since its count=2
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=1 label:Code-Review=1,count>=1", reviewPlus1Change);
+
+    assertQuery("label:Code-Review=MAX,count=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX,count=2");
+    assertQuery("label:Code-Review=MIN,count=1", reviewMinus2Change);
+    assertQuery("label:Code-Review=MIN,count>1");
+    assertQuery("label:Code-Review=MAX,count<2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1");
+    assertQuery("label:Code-Review=MAX,count<2 label:Code-Review=MAX,count>=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1 label:Code-Review=MIN,count>=1");
+    assertQuery("label:Code-Review>=+1,count=2", reviewTwoPlus1Change);
+
+    // "count" and "user" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class,
+        () -> assertQuery("label:Code-Review=+1,user=non_uploader,count=2"));
+
+    // "count" and "group" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
   }
 
   @Test
@@ -1224,16 +1294,15 @@
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
   }
 
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
-    int size = 0;
-    Change[] range = new Change[end - start + 1];
-    for (int i : changes.keySet()) {
+  private Change[] codeReviewInRange(Multimap<Integer, Change> changes, int start, int end) {
+    List<Change> range = new ArrayList<>();
+    for (Map.Entry<Integer, Change> entry : changes.entries()) {
+      int i = entry.getKey();
       if (i >= start && i <= end) {
-        range[size] = changes.get(i);
-        size++;
+        range.add(entry.getValue());
       }
     }
-    return range;
+    return range.toArray(new Change[0]);
   }
 
   private String createGroup(String name, String owner) throws Exception {
@@ -3891,6 +3960,33 @@
   }
 
   @Test
+  public void isPureRevert() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    Change.Id changeThatRevertsId = Change.id(changeThatReverts._number);
+    assertQueryByIds("is:pure-revert", changeThatRevertsId);
+
+    // Update the change that reverts such that it's not a pure revert
+    gApi.changes()
+        .id(changeThatReverts.id)
+        .edit()
+        .modifyFile("some-file.txt", RawInputUtil.create("newcontent".getBytes(UTF_8)));
+    gApi.changes().id(changeThatReverts.id).edit().publish();
+    assertQueryByIds("is:pure-revert");
+  }
+
+  @Test
   public void selfFailsForAnonymousUser() throws Exception {
     for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
       assertQuery(query);
diff --git a/plugins/package.json b/plugins/package.json
index 4e3c376..e5d245c 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -6,7 +6,7 @@
       "@polymer/decorators": "^3.0.0",
       "@polymer/polymer": "^3.4.1",
       "@gerritcodereview/typescript-api": "3.4.4",
-      "lit": "2.0.0-rc.3"
+      "lit": "^2.0.2"
     },
     "license": "Apache-2.0",
     "private": true
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3ff1cc4..4cbe489 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -7,10 +7,10 @@
   resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
   integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
 
-"@lit/reactive-element@^1.0.0-rc.2":
-  version "1.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.0-rc.2.tgz#f24dba16ea571a08dca70f1783bd2ca5ec8de3ee"
-  integrity sha512-cujeIl5Ei8FC7UHf4/4Q3bRJOtdTe1vpJV/JEBYCggedmQ+2P8A2oz7eE+Vxi6OJ4nc0X+KZxXnBoH4QrEbmEQ==
+"@lit/reactive-element@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
+  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -26,43 +26,36 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
-"@types/trusted-types@^1.0.1":
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da"
-  integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
-lit-element@^3.0.0-rc.2:
-  version "3.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.0-rc.2.tgz#883d0b6fd7b846226d360699d1b713da5fc7e1b7"
-  integrity sha512-2Z7DabJ3b5K+p5073vFjMODoaWqy5PIaI4y6ADKm+fCGc8OnX9fU9dMoUEBZjFpd/bEFR9PBp050tUtBnT9XTQ==
+lit-element@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
+  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-html "^2.0.0-rc.3"
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
 
-lit-html@^2.0.0-rc.3:
-  version "2.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.0-rc.3.tgz#1c216e548630e18d3093d97f4e29563abce659af"
-  integrity sha512-Y6P8LlAyQuqvzq6l/Nc4z5/P5M/rVLYKQIRxcNwSuGajK0g4kbcBFQqZmgvqKG+ak+dHZjfm2HUw9TF5N/pkCw==
+lit-html@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
+  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
+    "@types/trusted-types" "^2.0.2"
 
-lit-html@^2.0.0-rc.4:
-  version "2.0.0-rc.4"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.0-rc.4.tgz#1015fa8f1f7c8c5b79999ed0bc11c3b79ff1aab5"
-  integrity sha512-WSLGu3vxq7y8q/oOd9I3zxyBELNLLiDk6gAYoKK4PGctI5fbh6lhnO/jVBdy0PV/vTc+cLJCA/occzx3YoNPeg==
+lit@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
-
-lit@2.0.0-rc.3:
-  version "2.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.0-rc.3.tgz#8b6a85268aba287c11125dfe57e88e0bc09beaff"
-  integrity sha512-UZDLWuspl7saA+WvS0e+TE3NdGGE05hOIwUPTWiibs34c5QupcEzpjB/aElt79V9bELQVNbUUwa0Ow7D1Wuszw==
-  dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-element "^3.0.0-rc.2"
-    lit-html "^2.0.0-rc.4"
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 3a647d4..980abb4 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -97,7 +97,6 @@
     "elements/admin/gr-admin-view/gr-admin-view_html.ts",
     "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
     "elements/admin/gr-group-members/gr-group-members_html.ts",
-    "elements/admin/gr-group/gr-group_html.ts",
     "elements/admin/gr-permission/gr-permission_html.ts",
     "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
@@ -125,7 +124,6 @@
     "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
     "elements/diff/gr-diff-view/gr-diff-view_html.ts",
     "elements/diff/gr-diff/gr-diff_html.ts",
-    "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
     "elements/gr-app-element_html.ts",
     "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
     "elements/shared/gr-account-list/gr-account-list_html.ts",
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 39b40b6..e2b1502 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1065,7 +1065,7 @@
   url: string;
   /** URL to the icon of the link. */
   image_url?: string;
-  /* The links target. */
+  /* Value of the "target" attribute for anchor elements. */
   target?: string;
 }
 
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index a10bdda..0029f5c 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -98,4 +98,6 @@
   TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
   SHOW_TAB = 'show-tab',
   ATTENTION_SET_CHIP = 'attention-set-chip',
+  SAVE_COMMENT = 'save-comment',
+  COMMENT_SAVED = 'comment-saved',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index cf0fdd4..6bd1ac5 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -505,7 +505,7 @@
     suite('groups', () => {
       let getGroupConfigStub;
       setup(() => {
-        stub('gr-group', '_loadGroup').callsFake(() => Promise.resolve({}));
+        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve({}));
         stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
 
         getGroupConfigStub = stubRestApi('getGroupConfig');
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 a493747..63f6601 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,6 +20,8 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrSelect} from '../../shared/gr-select/gr-select';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
@@ -40,6 +42,15 @@
   }
 }
 
+export interface GrCreateRepoDialog {
+  $: {
+    initialCommit: GrSelect;
+    parentRepo: GrSelect;
+    repoNameInput: HTMLInputElement;
+    rightsInheritFromInput: GrAutocomplete;
+  };
+}
+
 @customElement('gr-create-repo-dialog')
 export class GrCreateRepoDialog extends PolymerElement {
   static get template() {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
index f529ac6..d0a6b7f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -36,24 +36,14 @@
     <div id="form">
       <section>
         <span class="title">Repository name</span>
-        <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
-          <input
-            is="iron-input"
-            id="repoNameInput"
-            autocomplete="on"
-            bind-value="{{_repoConfig.name}}"
-          />
+        <iron-input bind-value="{{_repoConfig.name}}">
+          <input id="repoNameInput" autocomplete="on" />
         </iron-input>
       </section>
       <section>
         <span class="title">Default Branch</span>
-        <iron-input autocomplete="off" bind-value="{{_defaultBranch}}">
-          <input
-            is="iron-input"
-            id="defaultBranchNameInput"
-            autocomplete="off"
-            bind-value="{{_defaultBranch}}"
-          />
+        <iron-input bind-value="{{_defaultBranch}}">
+          <input id="defaultBranchNameInput" autocomplete="off" />
         </iron-input>
       </section>
       <section>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
deleted file mode 100644
index f1babee..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-repo-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-create-repo-dialog');
-
-suite('gr-create-repo-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('default values are populated', () => {
-    assert.isTrue(element.$.initialCommit.bindValue);
-    assert.isFalse(element.$.parentRepo.bindValue);
-  });
-
-  test('repo created', async () => {
-    const configInputObj = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
-
-    assert.isFalse(element.hasNewRepoName);
-
-    element._repoConfig = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    element._repoOwner = 'test';
-    element._repoOwnerId = 'testId';
-    element._defaultBranch = 'main';
-
-    element.$.repoNameInput.bindValue = configInputObj.name;
-    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.initialCommit.bindValue =
-        configInputObj.create_empty_commit;
-    element.$.parentRepo.bindValue =
-        configInputObj.permissions_only;
-
-    assert.isTrue(element.hasNewRepoName);
-
-    assert.deepEqual(element._repoConfig, configInputObj);
-
-    await element.handleCreateRepo();
-    assert.isTrue(saveStub.lastCall.calledWithExactly(
-        {
-          ...configInputObj,
-          owners: ['testId'],
-          branches: ['main'],
-        }
-    ));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
new file mode 100644
index 0000000..6485bae
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright (C) 2017 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-create-repo-dialog';
+import {GrCreateRepoDialog} from './gr-create-repo-dialog';
+import {stubRestApi} from '../../../test/test-utils';
+import {BranchName, GroupId, RepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+
+suite('gr-create-repo-dialog tests', () => {
+  let element: GrCreateRepoDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('default values are populated', () => {
+    assert.isTrue(element.$.initialCommit.bindValue);
+    assert.isFalse(element.$.parentRepo.bindValue);
+  });
+
+  test('repo created', async () => {
+    const configInputObj = {
+      name: 'test-repo' as RepoName,
+      create_empty_commit: true,
+      parent: 'All-Project' as RepoName,
+      permissions_only: false,
+    };
+
+    const saveStub = stubRestApi('createRepo').returns(
+      Promise.resolve(new Response())
+    );
+
+    assert.isFalse(element.hasNewRepoName);
+
+    element._repoConfig = {
+      name: 'test-repo' as RepoName,
+      create_empty_commit: true,
+      parent: 'All-Project' as RepoName,
+      permissions_only: false,
+    };
+
+    element._repoOwner = 'test';
+    element._repoOwnerId = 'testId' as GroupId;
+    element._defaultBranch = 'main' as BranchName;
+
+    element.$.repoNameInput.value = configInputObj.name;
+    element.$.rightsInheritFromInput.value = configInputObj.parent;
+    element.$.initialCommit.bindValue = configInputObj.create_empty_commit;
+    element.$.parentRepo.bindValue = configInputObj.permissions_only;
+
+    assert.isTrue(element.hasNewRepoName);
+
+    assert.deepEqual(element._repoConfig, configInputObj);
+
+    await element.handleCreateRepo();
+    assert.isTrue(
+      saveStub.lastCall.calledWithExactly({
+        ...configInputObj,
+        owners: ['testId' as GroupId],
+        branches: ['main' as BranchName],
+      })
+    );
+  });
+});
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 596fe5b..d7ffbaf 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -15,29 +15,27 @@
  * limitations under the License.
  */
 
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import '../../shared/gr-textarea/gr-textarea';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {
-  fireEvent,
-  firePageError,
-  fireTitleChange,
-} from '../../../utils/event-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {convertToString} from '../../../utils/string-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -52,90 +50,267 @@
   },
 };
 
-export interface GrGroup {
-  $: {
-    loading: HTMLDivElement;
-  };
-}
-
 export interface GroupNameChangedDetail {
   name: GroupName;
   external: boolean;
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'text-changed': CustomEvent;
+    'value-changed': CustomEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
 }
 
 @customElement('gr-group')
-export class GrGroup extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrGroup extends LitElement {
   /**
    * Fired when the group name changes.
    *
    * @event name-changed
    */
 
+  private readonly query: AutocompleteQuery;
+
   @property({type: String})
   groupId?: GroupId;
 
-  @property({type: Boolean})
-  _rename = false;
+  @state() private originalOwnerName?: string;
 
-  @property({type: Boolean})
-  _groupIsInternal = false;
+  @state() private originalDescriptionName?: string;
 
-  @property({type: Boolean})
-  _description = false;
+  @state() private originalOptionsVisibleToAll?: boolean;
 
-  @property({type: Boolean})
-  _owner = false;
+  @state() private submitTypes = Object.values(OPTIONS);
 
-  @property({type: Boolean})
-  _options = false;
+  /* private but used in test */
+  @state() isAdmin = false;
 
-  @property({type: Boolean})
-  _loading = true;
+  /* private but used in test */
+  @state() groupOwner = false;
 
-  @property({type: Object})
-  _groupConfig?: GroupInfo;
+  /* private but used in test */
+  @state() groupIsInternal = false;
 
-  @property({type: String})
-  _groupConfigOwner?: string;
+  /* private but used in test */
+  @state() loading = true;
 
-  @property({type: Object})
-  _groupName?: string;
+  /* private but used in test */
+  @state() groupConfig?: GroupInfo;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  /* private but used in test */
+  @state() groupConfigOwner?: string;
 
-  @property({type: Array})
-  _submitTypes = Object.values(OPTIONS);
-
-  @property({type: Object})
-  _query: AutocompleteQuery;
-
-  @property({type: Boolean})
-  _isAdmin = false;
+  /* private but used in test */
+  @state() originalName?: GroupName;
 
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
-    this._query = (input: string) => this._getGroupSuggestions(input);
+    this.query = (input: string) => this.getGroupSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadGroup();
+    this.loadGroup();
   }
 
-  _loadGroup() {
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      subpageStyles,
+      css`
+        h3.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
+        <div id="loadedContent" class="${this.computeLoadingClass()}">
+          <h1 id="Title" class="heading-1">
+            ${convertToString(this.originalName)}
+          </h1>
+          <h2 id="configurations" class="heading-2">General</h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderGroupUUID()} ${this.renderGroupName()}
+              ${this.renderGroupOwner()} ${this.renderGroupDescription()}
+              ${this.renderGroupOptions()}
+            </fieldset>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderGroupUUID() {
+    return html`
+      <h3 id="groupUUID" class="heading-3">Group UUID</h3>
+      <fieldset>
+        <gr-copy-clipboard
+          id="uuid"
+          .text=${this.getGroupUUID()}
+        ></gr-copy-clipboard>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupName() {
+    const groupNameEdited = this.originalName !== this.groupConfig?.name;
+    return html`
+      <h3
+        id="groupName"
+        class="heading-3 ${this.computeHeaderClass(groupNameEdited)}"
+      >
+        Group Name
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupNameInput"
+            .text=${convertToString(this.groupConfig?.name)}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleNameTextChanged}
+          ></gr-autocomplete>
+        </span>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            id="inputUpdateNameBtn"
+            ?disabled=${!groupNameEdited}
+            @click=${this.handleSaveName}
+          >
+            Rename Group</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOwner() {
+    const groupOwnerNameEdited =
+      this.originalOwnerName !== this.groupConfig?.owner;
+    return html`
+      <h3
+        id="groupOwner"
+        class="heading-3 ${this.computeHeaderClass(groupOwnerNameEdited)}"
+      >
+        Owners
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupOwnerInput"
+            .text=${convertToString(this.groupConfig?.owner)}
+            .value=${convertToString(this.groupConfigOwner)}
+            .query=${this.query}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleOwnerTextChanged}
+            @value-changed=${this.handleOwnerValueChanged}
+          >
+          </gr-autocomplete>
+        </span>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            id="inputUpdateOwnerBtn"
+            ?disabled=${!groupOwnerNameEdited}
+            @click=${this.handleSaveOwner}
+          >
+            Change Owners</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupDescription() {
+    const groupDescriptionEdited =
+      this.originalDescriptionName !== this.groupConfig?.description;
+    return html`
+      <h3 class="heading-3 ${this.computeHeaderClass(groupDescriptionEdited)}">
+        Description
+      </h3>
+      <fieldset>
+        <div>
+          <gr-textarea
+            class="description"
+            autocomplete="on"
+            rows="4"
+            monospace
+            ?disabled=${this.computeGroupDisabled()}
+            .text=${convertToString(this.groupConfig?.description)}
+            @text-changed=${this.handleDescriptionTextChanged}
+          >
+        </div>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            ?disabled=${!groupDescriptionEdited}
+            @click=${this.handleSaveDescription}
+          >
+            Save Description
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOptions() {
+    const groupOptionsEdited =
+      this.originalOptionsVisibleToAll !==
+      this.groupConfig?.options?.visible_to_all;
+    return html`
+      <h3
+        id="options"
+        class="heading-3 ${this.computeHeaderClass(groupOptionsEdited)}"
+      >
+        Group Options
+      </h3>
+      <fieldset>
+        <section>
+          <span class="title">
+            Make group visible to all registered users
+          </span>
+          <span class="value">
+            <gr-select
+              id="visibleToAll"
+              .bindValue="${this.groupConfig?.options?.visible_to_all}"
+              @bind-value-changed=${this.handleOptionsBindValueChanged}
+            >
+              <select ?disabled=${this.computeGroupDisabled()}>
+                ${this.submitTypes.map(
+                  item => html`
+                    <option value=${item.value}>${item.label}</option>
+                  `
+                )}
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+          <gr-button
+            ?disabled=${!groupOptionsEdited}
+            @click=${this.handleSaveOptions}
+          >
+            Save Group Options
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  /* private but used in test */
+  async loadGroup() {
     if (!this.groupId) {
       return;
     }
@@ -146,154 +321,127 @@
       firePageError(response);
     };
 
-    return this.restApiService
-      .getGroupConfig(this.groupId, errFn)
-      .then(config => {
-        if (!config || !config.name) {
-          return Promise.resolve();
-        }
+    const config = await this.restApiService.getGroupConfig(
+      this.groupId,
+      errFn
+    );
+    if (!config || !config.name) return;
 
-        this._groupName = config.name;
-        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+    if (config.description === undefined) {
+      config.description = '';
+    }
 
-        promises.push(
-          this.restApiService.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
-          })
-        );
+    this.originalName = config.name;
+    this.originalOwnerName = config.owner;
+    this.originalDescriptionName = config.description;
+    this.groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-        promises.push(
-          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
-            this._groupOwner = !!isOwner;
-          })
-        );
+    promises.push(
+      this.restApiService.getIsAdmin().then(isAdmin => {
+        this.isAdmin = !!isAdmin;
+      })
+    );
 
-        // If visible to all is undefined, set to false. If it is defined
-        // as false, setting to false is fine. If any optional values
-        // are added with a default of true, then this would need to be an
-        // undefined check and not a truthy/falsy check.
-        if (config.options && !config.options.visible_to_all) {
-          config.options.visible_to_all = false;
-        }
-        this._groupConfig = config;
+    promises.push(
+      this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+        this.groupOwner = !!isOwner;
+      })
+    );
 
-        fireTitleChange(this, config.name);
+    // If visible to all is undefined, set to false. If it is defined
+    // as false, setting to false is fine. If any optional values
+    // are added with a default of true, then this would need to be an
+    // undefined check and not a truthy/falsy check.
+    if (config.options && !config.options.visible_to_all) {
+      config.options.visible_to_all = false;
+    }
+    this.groupConfig = config;
+    this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
-        return Promise.all(promises).then(() => {
-          this._loading = false;
-        });
-      });
+    fireTitleChange(this, config.name);
+
+    await Promise.all(promises);
+    this.loading = false;
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  /* private but used in test */
+  computeLoadingClass() {
+    return this.loading ? 'loading' : '';
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _handleSaveName() {
-    const groupConfig = this._groupConfig;
+  /* private but used in test */
+  async handleSaveName() {
+    const groupConfig = this.groupConfig;
     if (!this.groupId || !groupConfig || !groupConfig.name) {
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.restApiService
-      .saveGroupName(this.groupId, groupName)
-      .then(config => {
-        if (config.status === 200) {
-          this._groupName = groupName;
-          const detail: GroupNameChangedDetail = {
-            name: groupName,
-            external: !this._groupIsInternal,
-          };
-          fireEvent(this, 'name-changed');
-          this.dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail,
-              composed: true,
-              bubbles: true,
-            })
-          );
-          this._rename = false;
-        }
-      });
+    const config = await this.restApiService.saveGroupName(
+      this.groupId,
+      groupName
+    );
+    if (config.status === 200) {
+      this.originalName = groupName;
+      const detail: GroupNameChangedDetail = {
+        name: groupName,
+        external: !this.groupIsInternal,
+      };
+      this.dispatchEvent(
+        new CustomEvent('name-changed', {
+          detail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      this.requestUpdate();
+    }
+
+    return;
   }
 
-  _handleSaveOwner() {
-    if (!this.groupId || !this._groupConfig) return;
-    let owner = this._groupConfig.owner;
-    if (this._groupConfigOwner) {
-      owner = decodeURIComponent(this._groupConfigOwner);
+  /* private but used in test */
+  async handleSaveOwner() {
+    if (!this.groupId || !this.groupConfig) return;
+    let owner = this.groupConfig.owner;
+    if (this.groupConfigOwner) {
+      owner = decodeURIComponent(this.groupConfigOwner);
     }
     if (!owner) return;
-    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
-      this._owner = false;
-    });
+    await this.restApiService.saveGroupOwner(this.groupId, owner);
+    this.originalOwnerName = this.groupConfig?.owner;
+    this.groupConfigOwner = undefined;
   }
 
-  _handleSaveDescription() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
+  /* private but used in test */
+  async handleSaveDescription() {
+    if (
+      !this.groupId ||
+      !this.groupConfig ||
+      this.groupConfig.description === undefined
+    )
       return;
-    return this.restApiService
-      .saveGroupDescription(this.groupId, this._groupConfig.description)
-      .then(() => {
-        this._description = false;
-      });
+    await this.restApiService.saveGroupDescription(
+      this.groupId,
+      this.groupConfig.description
+    );
+    this.originalDescriptionName = this.groupConfig.description;
   }
 
-  _handleSaveOptions() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.options)
-      return;
-    const visible = this._groupConfig.options.visible_to_all;
-
+  /* private but used in test */
+  async handleSaveOptions() {
+    if (!this.groupId || !this.groupConfig || !this.groupConfig.options) return;
+    const visible = this.groupConfig.options.visible_to_all;
     const options = {visible_to_all: visible};
-
-    return this.restApiService
-      .saveGroupOptions(this.groupId, options)
-      .then(() => {
-        this._options = false;
-      });
+    await this.restApiService.saveGroupOptions(this.groupId, options);
+    this.originalOptionsVisibleToAll =
+      this.groupConfig?.options?.visible_to_all;
   }
 
-  @observe('_groupConfig.name')
-  _handleConfigName() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._rename = true;
-  }
-
-  @observe('_groupConfig.owner', '_groupConfigOwner')
-  _handleConfigOwner() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._owner = true;
-  }
-
-  @observe('_groupConfig.description')
-  _handleConfigDescription() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._description = true;
-  }
-
-  @observe('_groupConfig.options.visible_to_all')
-  _handleConfigOptions() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._options = true;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
+  private computeHeaderClass(configChanged: boolean) {
     return configChanged ? 'edited' : '';
   }
 
-  _getGroupSuggestions(input: string) {
+  private getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
       for (const [name, group] of Object.entries(response ?? {})) {
@@ -303,17 +451,45 @@
     });
   }
 
-  _computeGroupDisabled(
-    owner: boolean,
-    admin: boolean,
-    groupIsInternal: boolean
-  ) {
-    return !(groupIsInternal && (admin || owner));
+  /* private but used in test */
+  computeGroupDisabled() {
+    return !(this.groupIsInternal && (this.isAdmin || this.groupOwner));
   }
 
-  _getGroupUUID(id: GroupId) {
+  private getGroupUUID() {
+    const id = this.groupConfig?.id;
     if (!id) return;
-
     return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
   }
+
+  private handleNameTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.name = e.detail.value as GroupName;
+    this.requestUpdate();
+  }
+
+  private handleOwnerTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.owner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOwnerValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupConfigOwner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleDescriptionTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.description = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOptionsBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.groupConfig || !this.groupConfig.options || this.loading) return;
+    this.groupConfig.options.visible_to_all = e.detail
+      .value as unknown as boolean;
+    this.requestUpdate();
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
deleted file mode 100644
index 6bc5d2a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    h3.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="main gr-form-styles read-only">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
-      <h2 id="configurations" class="heading-2">General</h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
-          <fieldset>
-            <gr-copy-clipboard
-              id="uuid"
-              text="[[_getGroupUUID(_groupConfig.id)]]"
-            ></gr-copy-clipboard>
-          </fieldset>
-          <h3
-            id="groupName"
-            class$="heading-3 [[_computeHeaderClass(_rename)]]"
-          >
-            Group Name
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupNameInput"
-                text="{{_groupConfig.name}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateNameBtn"
-                on-click="_handleSaveName"
-                disabled="[[!_rename]]"
-              >
-                Rename Group</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3
-            id="groupOwner"
-            class$="heading-3 [[_computeHeaderClass(_owner)]]"
-          >
-            Owners
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupOwnerInput"
-                text="{{_groupConfig.owner}}"
-                value="{{_groupConfigOwner}}"
-                query="[[_query]]"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              >
-              </gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateOwnerBtn"
-                on-click="_handleSaveOwner"
-                disabled="[[!_owner]]"
-              >
-                Change Owners</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3 class$="heading-3 [[_computeHeaderClass(_description)]]">
-            Description
-          </h3>
-          <fieldset>
-            <div>
-              <iron-autogrow-textarea
-                class="description"
-                autocomplete="on"
-                bind-value="{{_groupConfig.description}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></iron-autogrow-textarea>
-            </div>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                on-click="_handleSaveDescription"
-                disabled="[[!_description]]"
-              >
-                Save Description
-              </gr-button>
-            </span>
-          </fieldset>
-          <h3 id="options" class$="heading-3 [[_computeHeaderClass(_options)]]">
-            Group Options
-          </h3>
-          <fieldset>
-            <section>
-              <span class="title">
-                Make group visible to all registered users
-              </span>
-              <span class="value">
-                <gr-select
-                  id="visibleToAll"
-                  bind-value="{{_groupConfig.options.visible_to_all}}"
-                >
-                  <select
-                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-                  >
-                    <template is="dom-repeat" items="[[_submitTypes]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
-                Save Group Options
-              </gr-button>
-            </span>
-          </fieldset>
-        </fieldset>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
deleted file mode 100644
index e390ac5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-group.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-group');
-
-suite('gr-group tests', () => {
-  let element;
-
-  let groupStub;
-  const group = {
-    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    options: {},
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    name: 'Administrators',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
-  });
-
-  test('loading displays before group config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('default values are populated with internal group', async () => {
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isTrue(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('default values with external group', async () => {
-    const groupExternal = {...group};
-    groupExternal.id = 'external-group-id';
-    groupStub.restore();
-    groupStub = stubRestApi('getGroupConfig').returns(
-        Promise.resolve(groupExternal));
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isFalse(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('rename group', async () => {
-    const groupName = 'test-group';
-    const groupName2 = 'test-group2';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupName = groupName;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateNameBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupNameInput.text = groupName2;
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-    await element._handleSaveName();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-    assert.equal(element._groupName, groupName2);
-  });
-
-  test('rename group owner', async () => {
-    const groupName = 'test-group';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupConfigOwner = 'testId';
-    element._groupOwner = true;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateOwnerBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupOwnerInput.text = 'testId2';
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupOwner.classList.contains('edited'));
-
-    await element._handleSaveOwner();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-  });
-
-  test('test for undefined group name', async () => {
-    groupStub.restore();
-
-    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
-
-    assert.isUndefined(element.groupId);
-
-    element.groupId = 1;
-
-    assert.isDefined(element.groupId);
-
-    // Test that loading shows instead of filling
-    // in group details
-    await element._loadGroup();
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-
-    assert.isTrue(element._loading);
-  });
-
-  test('test fire event', async () => {
-    element._groupConfig = {
-      name: 'test-group',
-    };
-    element.groupId = 'gg';
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const showStub = sinon.stub(element, 'dispatchEvent');
-    await element._handleSaveName();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_computeGroupDisabled', () => {
-    let admin = true;
-    let owner = false;
-    let groupIsInternal = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    admin = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    owner = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    owner = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    groupIsInternal = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    admin = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', async () => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element._loadGroup();
-    await promise;
-  });
-
-  test('uuid', () => {
-    element._groupConfig = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    };
-
-    assert.equal(element._groupConfig.id, element.$.uuid.text);
-
-    element._groupConfig = {
-      id: 'user%2Fgroup',
-    };
-
-    assert.equal('user/group', element.$.uuid.text);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
new file mode 100644
index 0000000..5e96e33
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright (C) 2017 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-group';
+import {GrGroup} from './gr-group';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-group');
+
+suite('gr-group tests', () => {
+  let element: GrGroup;
+  let groupStub: sinon.SinonStub;
+
+  const group: GroupInfo = {
+    ...createGroupInfo('6a1e70e1a88782771a91808c8af9bbb7a9871389'),
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators' as GroupName,
+  };
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
+  });
+
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+    assert.isFalse(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
+        .display === 'none'
+    );
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#loadedContent'
+      ).classList.contains('loading')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
+      ).display === 'none'
+    );
+  });
+
+  test('default values are populated with internal group', async () => {
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isTrue(element.groupIsInternal);
+    assert.isFalse(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    );
+  });
+
+  test('default values with external group', async () => {
+    const groupExternal = {...group};
+    groupExternal.id = 'external-group-id' as GroupId;
+    groupStub.restore();
+    groupStub = stubRestApi('getGroupConfig').returns(
+      Promise.resolve(groupExternal)
+    );
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isFalse(element.groupIsInternal);
+    assert.isFalse(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    );
+  });
+
+  test('rename group', async () => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.originalName = groupName as GroupName;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateNameBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupNameInput').text =
+      groupName2;
+
+    await element.updateComplete;
+
+    assert.isFalse(button.hasAttribute('disabled'));
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupName'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveName();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+    assert.equal(element.originalName, groupName2);
+  });
+
+  test('rename group owner', async () => {
+    const groupName = 'test-group';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupConfigOwner = 'testId';
+    element.groupOwner = true;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateOwnerBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupOwnerInput').text =
+      'testId2';
+
+    await element.updateComplete;
+    assert.isFalse(button.disabled);
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupOwner'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveOwner();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+  });
+
+  test('test for undefined group name', async () => {
+    groupStub.restore();
+
+    stubRestApi('getGroupConfig').returns(Promise.resolve(undefined));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = '1' as GroupId;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    await element.loadGroup();
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+
+    assert.isTrue(element.loading);
+  });
+
+  test('test fire event', async () => {
+    element.groupConfig = {
+      name: 'test-group' as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupId = 'gg' as GroupId;
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const showStub = sinon.stub(element, 'dispatchEvent');
+    await element.handleSaveName();
+    assert.isTrue(showStub.called);
+  });
+
+  test('computeGroupDisabled', () => {
+    element.isAdmin = true;
+    element.groupOwner = false;
+    element.groupIsInternal = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.isAdmin = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupOwner = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.groupOwner = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupIsInternal = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.isAdmin = true;
+    assert.equal(element.computeGroupDisabled(), true);
+  });
+
+  test('computeLoadingClass', () => {
+    element.loading = true;
+    assert.equal(element.computeLoadingClass(), 'loading');
+    element.loading = false;
+    assert.equal(element.computeLoadingClass(), '');
+  });
+
+  test('fires page-error', async () => {
+    groupStub.restore();
+
+    element.groupId = '1' as GroupId;
+
+    const response = {...new Response(), status: 404};
+    stubRestApi('getGroupConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      } else {
+        assert.fail('errFn is undefined');
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as CustomEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    await element.loadGroup();
+    await promise;
+  });
+
+  test('uuid', async () => {
+    element.groupConfig = {
+      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      element.groupConfig.id,
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+
+    element.groupConfig = {
+      id: 'user%2Fgroup' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      'user/group',
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+  });
+});
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 0c34a84..b7ea237 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -18,6 +18,7 @@
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
index 3559194..a8405df 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -70,6 +70,9 @@
       display: block;
     }
   </style>
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
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 32812dd..7092c9b 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
@@ -36,6 +36,7 @@
   PluginConfigOptionsChangedEventDetail,
   PluginOption,
 } from './gr-repo-plugin-config-types';
+import {paperStyles} from '../../../styles/gr-paper-styles';
 
 const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
 
@@ -71,6 +72,7 @@
     return [
       sharedStyles,
       formStyles,
+      paperStyles,
       subpageStyles,
       css`
         .inherited {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
index 2b9abfd..b5dee28 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
@@ -20,6 +20,7 @@
 import {customElement, property} from 'lit/decorators';
 import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
 import {changeIsMerged} from '../../../utils/change-util';
+import {getRequirements} from '../../../utils/label-util';
 
 @customElement('gr-change-list-column-requirements')
 export class GrChangeListColumRequirements extends LitElement {
@@ -52,13 +53,13 @@
       return this.renderState('check', 'Merged');
     }
 
-    const submitRequirements = (this.change?.submit_requirements ?? []).filter(
-      req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
-    );
+    const submitRequirements = getRequirements(this.change);
     if (!submitRequirements.length) return html`n/a`;
     const numOfRequirements = submitRequirements.length;
     const numOfSatisfiedRequirements = submitRequirements.filter(
-      req => req.status === SubmitRequirementStatus.SATISFIED
+      req =>
+        req.status === SubmitRequirementStatus.SATISFIED ||
+        req.status === SubmitRequirementStatus.OVERRIDDEN
     ).length;
 
     if (numOfSatisfiedRequirements === numOfRequirements) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index c59b2e3..cd55e15 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -46,7 +46,8 @@
       width: 100%;
     }
     .comments,
-    .reviewers {
+    .reviewers,
+    .requirements {
       white-space: nowrap;
     }
     .reviewers {
@@ -120,7 +121,7 @@
   </style>
   <td aria-hidden="true" class="cell leftPadding"></td>
   <td class="cell star" hidden$="[[!showStar]]" hidden="">
-    <gr-change-star change="{{change}}"></gr-change-star>
+    <gr-change-star change="[[change]]"></gr-change-star>
   </td>
   <td class="cell number" hidden$="[[!showNumber]]" hidden="">
     <a href$="[[changeURL]]">[[change._number]]</a>
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 66cef20..68566f0 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
@@ -16,9 +16,10 @@
  */
 
 import '../../../styles/gr-change-list-styles';
+import '../../../styles/gr-font-styles';
+import '../../../styles/shared-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -51,6 +52,7 @@
 import {ScrollMode} from '../../../constants/constants';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {PRIORITY_REQUIREMENTS_ORDER} from '../../../utils/label-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -320,6 +322,13 @@
         labels = labels.concat(currentLabels.filter(nonExistingLabel));
       }
     }
+    if (
+      this.flagsService.enabledExperiments.includes(
+        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      )
+    ) {
+      labels = labels.filter(l => PRIORITY_REQUIREMENTS_ORDER.includes(l));
+    }
     return labels.sort();
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index c31da77..77320b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -20,6 +20,9 @@
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="gr-change-list-styles">
     #changeList {
       border-collapse: collapse;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index ad8f72f..e4b466b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -232,6 +232,9 @@
           height: var(--line-height-small);
           vertical-align: top;
         }
+        .checksChip a iron-icon.launch {
+          color: var(--link-color);
+        }
         .checksChip.error {
           color: var(--error-foreground);
           border-color: var(--error-foreground);
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 1a4b7a0..8d64bc0 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
@@ -16,6 +16,7 @@
  */
 import '@polymer/paper-tabs/paper-tabs';
 import '../../../styles/gr-a11y-styles';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-comment-api/gr-comment-api';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -42,6 +43,7 @@
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
 import '../../checks/gr-checks-tab';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-view_html';
@@ -61,12 +63,12 @@
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {DiffViewMode} from '../../../api/diff';
 import {
   ChangeStatus,
   DefaultBase,
   PrimaryTab,
   SecondaryTab,
+  DiffViewMode,
 } from '../../../constants/constants';
 
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
@@ -131,7 +133,11 @@
   ChangeComments,
   GrCommentApi,
 } from '../../diff/gr-comment-api/gr-comment-api';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {
+  assertIsDefined,
+  hasOwnProperty,
+  query,
+} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
@@ -234,7 +240,6 @@
     downloadOverlay: GrOverlay;
     downloadDialog: GrDownloadDialog;
     replyOverlay: GrOverlay;
-    replyDialog: GrReplyDialog;
     mainContent: HTMLDivElement;
     changeStar: GrChangeStar;
     actions: GrChangeActions;
@@ -519,7 +524,7 @@
   _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
 
   @property({type: Boolean})
-  unresolvedOnly = false;
+  unresolvedOnly = true;
 
   @property({type: Boolean})
   _showAllRobotComments = false;
@@ -544,6 +549,10 @@
   @property({type: String})
   scrollCommentId?: UrlEncodedCommentId;
 
+  /** Just reflects the `opened` prop of the overlay. */
+  @property({type: Boolean})
+  replyOverlayOpened = false;
+
   @property({
     type: Array,
     computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
@@ -552,12 +561,12 @@
 
   restApiService = appContext.restApiService;
 
+  private readonly userService = appContext.userService;
+
   private readonly commentsService = appContext.commentsService;
 
   private readonly shortcuts = appContext.shortcutsService;
 
-  private replyDialogResizeObserver?: ResizeObserver;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -612,10 +621,14 @@
 
   private lastStarredTimestamp?: number;
 
-  private readonly userService = appContext.userService;
-
   private diffViewMode?: DiffViewMode;
 
+  /**
+   * If the user comes back to the change page we want to remember the scroll
+   * position when we re-render the page as is.
+   */
+  private scrollPosition?: number;
+
   override ready() {
     super.ready();
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
@@ -678,11 +691,6 @@
       }
     });
 
-    this.replyDialogResizeObserver = new ResizeObserver(() =>
-      this.$.replyOverlay.center()
-    );
-    this.replyDialogResizeObserver.observe(this.$.replyDialog);
-
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -709,6 +717,7 @@
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
 
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
@@ -727,6 +736,7 @@
       'visibilitychange',
       this.handleVisibilityChange
     );
+    document.removeEventListener('scroll', this.handleScroll);
     this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
@@ -744,6 +754,15 @@
     return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
+  private readonly handleScroll = () => {
+    if (!this.isViewCurrent) return;
+    this.scrollTask = debounce(
+      this.scrollTask,
+      () => (this.scrollPosition = document.documentElement.scrollTop),
+      150
+    );
+  };
+
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
   }
@@ -865,7 +884,7 @@
       const hasUnresolvedThreads =
         (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
           .length > 0;
-      if (hasUnresolvedThreads) this.unresolvedOnly = true;
+      if (!hasUnresolvedThreads) this.unresolvedOnly = false;
     }
 
     this.reporting.reportInteraction(Interaction.SHOW_TAB, {
@@ -1055,7 +1074,7 @@
 
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    this._openReplyDialog(FocusTarget.ANY);
   }
 
   onReplyOverlayCanceled() {
@@ -1099,8 +1118,7 @@
         .split('\n')
         .map(line => '> ' + line)
         .join('\n') + '\n\n';
-    this.$.replyDialog.quote = quoteStr;
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+    this._openReplyDialog(FocusTarget.BODY, quoteStr);
   }
 
   _handleHideBackgroundContent() {
@@ -1137,9 +1155,9 @@
   }
 
   _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
-    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
-      target = this.$.replyDialog.FocusTarget.CCS;
+      target = FocusTarget.CCS;
     }
     this._openReplyDialog(target);
   }
@@ -1178,6 +1196,24 @@
     return this._changeNum !== this.params?.changeNum;
   }
 
+  hasPatchRangeChanged(value: AppElementChangeViewParams) {
+    if (!this._patchRange) return false;
+    if (this._patchRange.basePatchNum !== value.basePatchNum) return true;
+    return this.hasPatchNumChanged(value);
+  }
+
+  hasPatchNumChanged(value: AppElementChangeViewParams) {
+    if (!this._patchRange) return false;
+    if (value.patchNum !== undefined) {
+      return this._patchRange.patchNum !== value.patchNum;
+    } else {
+      // value.patchNum === undefined specifies the latest patchset
+      return (
+        this._patchRange.patchNum !== computeLatestPatchNum(this._allPatchSets)
+      );
+    }
+  }
+
   _paramsChanged(value: AppElementChangeViewParams) {
     if (value.view !== GerritView.CHANGE) {
       this._initialLoadComplete = false;
@@ -1201,43 +1237,53 @@
     if (value.basePatchNum === undefined)
       value.basePatchNum = ParentPatchSetNum;
 
-    const patchChanged =
-      this._patchRange &&
-      value.patchNum !== undefined &&
-      (this._patchRange.patchNum !== value.patchNum ||
-        this._patchRange.basePatchNum !== value.basePatchNum);
+    const patchChanged = this.hasPatchRangeChanged(value);
+    let patchNumChanged = this.hasPatchNumChanged(value);
 
-    let rightPatchNumChanged =
-      this._patchRange &&
-      value.patchNum !== undefined &&
-      this._patchRange.patchNum !== value.patchNum;
-
-    const patchRange: ChangeViewPatchRange = {
+    this._patchRange = {
       patchNum: value.patchNum,
       basePatchNum: value.basePatchNum,
     };
-
-    this.$.fileList.collapseAllDiffs();
-    this._patchRange = patchRange;
     this.scrollCommentId = value.commentId;
 
     const patchKnown =
-      !patchRange.patchNum ||
-      (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum);
+      !this._patchRange.patchNum ||
+      (this._allPatchSets ?? []).some(
+        ps => ps.num === this._patchRange!.patchNum
+      );
+    // _allPatchsets does not know value.patchNum so force a reload.
+    const forceReload = value.forceReload || !patchKnown;
 
-    // If the change has already been loaded and the parameter change is only
-    // in the patch range, then don't do a full reload.
-    if (this._changeNum !== undefined && patchChanged && patchKnown) {
-      if (!patchRange.patchNum) {
-        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
-        rightPatchNumChanged = true;
+    // If changeNum is defined that means the change has already been
+    // rendered once before so a full reload is not required.
+    if (this._changeNum !== undefined && !forceReload) {
+      if (!this._patchRange.patchNum) {
+        this._patchRange = {
+          ...this._patchRange,
+          patchNum: computeLatestPatchNum(this._allPatchSets),
+        };
+        patchNumChanged = true;
       }
-      this._reloadPatchNumDependentResources(rightPatchNumChanged).then(() => {
-        this._sendShowChangeEvent();
-      });
+      if (patchChanged) {
+        // We need to collapse all diffs when params change so that a non
+        // existing diff is not requested. See Issue 125270 for more details.
+        this.$.fileList.collapseAllDiffs();
+        this._reloadPatchNumDependentResources(patchNumChanged).then(() => {
+          this._sendShowChangeEvent();
+        });
+      }
+
+      // If there is no change in patchset or changeNum, such as when user goes
+      // to the diff view and then comes back to change page then there is no
+      // need to reload anything and we render the change view component as is.
+      document.documentElement.scrollTop = this.scrollPosition ?? 0;
       return;
     }
 
+    // We need to collapse all diffs when params change so that a non existing
+    // diff is not requested. See Issue 125270 for more details.
+    this.$.fileList.collapseAllDiffs();
+
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
     this.loadData(true).then(() => {
@@ -1253,8 +1299,8 @@
 
   _initActiveTabs(params?: AppElementChangeViewParams) {
     let primaryTab = PrimaryTab.FILES;
-    if (params && params.queryMap && params.queryMap.has('tab')) {
-      primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    if (params?.tab) {
+      primaryTab = params?.tab as PrimaryTab;
     } else if (params && 'commentId' in params) {
       primaryTab = PrimaryTab.COMMENT_THREADS;
     }
@@ -1390,7 +1436,7 @@
       }
 
       if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        this._openReplyDialog(FocusTarget.ANY);
         this.set('viewState.showReplyDialog', false);
       }
     });
@@ -1496,7 +1542,7 @@
         fireEvent(this, 'show-auth-required');
         return;
       }
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+      this._openReplyDialog(FocusTarget.ANY);
     });
   }
 
@@ -1681,12 +1727,17 @@
     });
   }
 
-  _openReplyDialog(section?: FocusTarget) {
+  _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
     if (!this._change) return;
-    this.$.replyOverlay.open().finally(() => {
+    const overlay = this.$.replyOverlay;
+    overlay.open().finally(async () => {
       // the following code should be executed no matter open succeed or not
+      const dialog = query<GrReplyDialog>(this, '#replyDialog');
+      assertIsDefined(dialog, 'reply dialog');
       this._resetReplyOverlayFocusStops();
-      this.$.replyDialog.open(section);
+      dialog.open(focusTarget, quote);
+      const observer = new ResizeObserver(() => overlay.center());
+      observer.observe(dialog);
     });
     fireDialogChange(this, {opened: true});
     this._changeViewAriaHidden = true;
@@ -2017,8 +2068,8 @@
    *
    * @param isLocationChange Reloads the related changes
    * when true and ends reporting events that started on location change.
-   * @param clearPatchset Reloads the related changes
-   * ignoring any patchset choice made.
+   * @param clearPatchset Reloads the change ignoring any patchset
+   * choice made.
    * @return A promise that resolves when the core data has loaded.
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
@@ -2026,7 +2077,14 @@
   loadData(isLocationChange?: boolean, clearPatchset?: boolean): Promise<void> {
     if (this.isChangeObsolete()) return Promise.resolve();
     if (clearPatchset && this._change) {
-      GerritNav.navigateToChange(this._change);
+      GerritNav.navigateToChange(
+        this._change,
+        undefined,
+        undefined,
+        undefined,
+        undefined,
+        true
+      );
       return Promise.resolve();
     }
     this._loading = true;
@@ -2168,11 +2226,11 @@
    * Kicks off requests for resources that rely on the patch range
    * (`this._patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
+  _reloadPatchNumDependentResources(patchNumChanged?: boolean) {
     assertIsDefined(this._changeNum, '_changeNum');
     if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
     const promises = [this._getCommitInfo(), this.$.fileList.reload()];
-    if (rightPatchNumChanged)
+    if (patchNumChanged)
       promises.push(
         this.$.commentAPI.reloadPortedComments(
           this._changeNum,
@@ -2422,8 +2480,8 @@
   }
 
   @observe('_patchRange.patchNum')
-  _patchNumChanged(patchNumStr: PatchSetNum) {
-    if (!this._selectedRevision) {
+  _patchNumChanged(patchNumStr?: PatchSetNum) {
+    if (!this._selectedRevision || !patchNumStr) {
       return;
     }
     assertIsDefined(this._change, '_change');
@@ -2470,21 +2528,37 @@
     ) {
       patchNum = this._patchRange.patchNum;
     }
-    GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+    GerritNav.navigateToChange(
+      this._change,
+      patchNum,
+      undefined,
+      true,
+      undefined,
+      true
+    );
   }
 
   _handleStopEditTap() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+    GerritNav.navigateToChange(
+      this._change,
+      this._patchRange.patchNum,
+      undefined,
+      undefined,
+      undefined,
+      true
+    );
   }
 
   _resetReplyOverlayFocusStops() {
-    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+    const dialog = query<GrReplyDialog>(this, '#replyDialog');
+    if (!dialog) return;
+    this.$.replyOverlay.setFocusStops(dialog.getFocusStops());
   }
 
-  _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-view');
       this.lastStarredTimestamp = Date.now();
@@ -2562,6 +2636,9 @@
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'toggle-star': CustomEvent<ChangeStarToggleStarDetail>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-view': GrChangeView;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index d42fc17..9341b18 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -20,6 +20,9 @@
   <style include="gr-a11y-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     .container:not(.loading) {
       background-color: var(--background-color-tertiary);
@@ -335,7 +338,7 @@
           </div>
           <gr-change-star
             id="changeStar"
-            change="{{_change}}"
+            change="[[_change]]"
             on-toggle-star="_handleToggleStar"
             hidden$="[[!_loggedIn]]"
           ></gr-change-star>
@@ -695,24 +698,27 @@
     no-cancel-on-esc-key=""
     scroll-action="lock"
     with-backdrop=""
+    opened="{{replyOverlayOpened}}"
     on-iron-overlay-canceled="onReplyOverlayCanceled"
   >
-    <gr-reply-dialog
-      id="replyDialog"
-      change="{{_change}}"
-      patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-      permitted-labels="[[_change.permitted_labels]]"
-      draft-comment-threads="[[_draftCommentThreads]]"
-      project-config="[[_projectConfig]]"
-      server-config="[[_serverConfig]]"
-      can-be-started="[[_canStartReview]]"
-      on-send="_handleReplySent"
-      on-cancel="_handleReplyCancel"
-      on-autogrow="_handleReplyAutogrow"
-      on-send-disabled-changed="_resetReplyOverlayFocusStops"
-      hidden$="[[!_loggedIn]]"
-    >
-    </gr-reply-dialog>
+    <template is="dom-if" if="[[replyOverlayOpened]]">
+      <gr-reply-dialog
+        id="replyDialog"
+        change="{{_change}}"
+        patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+        permitted-labels="[[_change.permitted_labels]]"
+        draft-comment-threads="[[_draftCommentThreads]]"
+        project-config="[[_projectConfig]]"
+        server-config="[[_serverConfig]]"
+        can-be-started="[[_canStartReview]]"
+        on-send="_handleReplySent"
+        on-cancel="_handleReplyCancel"
+        on-autogrow="_handleReplyAutogrow"
+        on-send-disabled-changed="_resetReplyOverlayFocusStops"
+        hidden$="[[!_loggedIn]]"
+      >
+      </gr-reply-dialog>
+    </template>
   </gr-overlay>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
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 5fcc3b0..ab17f47 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
@@ -27,6 +27,7 @@
   MessageTag,
   PrimaryTab,
   createDefaultPreferences,
+  createDefaultDiffPrefs,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -36,7 +37,14 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {mockPromise, stubRestApi, stubUsers} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+  stubUsers,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -96,6 +104,9 @@
 import {appContext} from '../../../services/app-context';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {_testOnly_setState} from '../../../services/user/user-model';
+import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -547,13 +558,12 @@
 
     test('param change should switch primary tab correctly', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map<string, string>();
-      queryMap.set('tab', PrimaryTab.FINDINGS);
       // view is required
+      element._changeNum = undefined;
       element.params = {
         ...createAppElementChangeViewParams(),
         ...element.params,
-        queryMap,
+        tab: PrimaryTab.FINDINGS,
       };
       await flush();
       assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
@@ -561,13 +571,11 @@
 
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map<string, string>();
-      queryMap.set('tab', 'random');
       // view is required
       element.params = {
         ...createAppElementChangeViewParams(),
         ...element.params,
-        queryMap,
+        tab: 'random',
       };
       await flush();
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
@@ -680,9 +688,7 @@
       element.$.replyOverlay.close();
       assert.isFalse(element.$.replyOverlay.opened);
       assert(
-        openSpy.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.ANY
-        ),
+        openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
         '_openReplyDialog should have been passed ANY'
       );
       assert.equal(openSpy.callCount, 1);
@@ -704,7 +710,8 @@
         },
       };
       const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
+      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
+      overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-opened', {
           composed: true,
           bubbles: true,
@@ -731,7 +738,8 @@
         },
       };
       const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
+      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
+      overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-closed', {
           composed: true,
           bubbles: true,
@@ -813,7 +821,10 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      _testOnly_setState({preferences: prefs});
+      _testOnly_setState({
+        preferences: prefs,
+        diffPreferences: createDefaultDiffPrefs(),
+      });
       element._handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -823,7 +834,10 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      _testOnly_setState({preferences: newPrefs});
+      _testOnly_setState({
+        preferences: newPrefs,
+        diffPreferences: createDefaultDiffPrefs(),
+      });
       await flush();
       element._handleToggleDiffMode();
       assert.isTrue(
@@ -1299,12 +1313,12 @@
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
     flush();
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-
     const value: AppElementChangeViewParams = {
       ...createAppElementChangeViewParams(),
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
+    element._changeNum = undefined;
     element.params = value;
     await flush();
     assert.isTrue(reloadStub.calledOnce);
@@ -1362,7 +1376,7 @@
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
   });
 
-  test('reload entire page when patchRange doesnt change', async () => {
+  test('do not reload entire page when patchRange doesnt change', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
@@ -1370,13 +1384,15 @@
     const value: AppElementChangeViewParams =
       createAppElementChangeViewParams();
     element.params = value;
+    // change already loaded
+    assert.isOk(element._changeNum);
     await flush();
-    assert.isTrue(reloadStub.calledOnce);
+    assert.isFalse(reloadStub.calledOnce);
     element._initialLoadComplete = true;
     element.params = {...value};
     await flush();
-    assert.isTrue(reloadStub.calledTwice);
-    assert.isTrue(collapseStub.calledTwice);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isFalse(collapseStub.calledTwice);
   });
 
   test('do not handle new change numbers', async () => {
@@ -1601,9 +1617,7 @@
     const openStub = sinon.stub(element, '_openReplyDialog');
     tap(element.$.replyBtn);
     assert(
-      openStub.lastCall.calledWithExactly(
-        element.$.replyDialog.FocusTarget.ANY
-      ),
+      openStub.lastCall.calledWithExactly(FocusTarget.ANY),
       '_openReplyDialog should have been passed ANY'
     );
     assert.equal(openStub.callCount, 1);
@@ -1622,18 +1636,12 @@
           bubbles: true,
         })
       );
-      assert(
-        openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.BODY
-        ),
-        '_openReplyDialog should have been passed BODY'
-      );
-      assert.equal(openStub.callCount, 1);
+      assert.isTrue(openStub.calledOnce);
+      assert.equal(openStub.lastCall.args[0], FocusTarget.BODY);
     }
   );
 
   test('reply dialog focus can be controlled', () => {
-    const FocusTarget = element.$.replyDialog.FocusTarget;
     const openStub = sinon.stub(element, '_openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
@@ -1709,7 +1717,6 @@
 
   suite('reply dialog tests', () => {
     setup(() => {
-      sinon.stub(element.$.replyDialog, '_draftChanged');
       element._change = {
         ...createChangeViewChange(),
         revisions: createRevisions(1),
@@ -1740,52 +1747,18 @@
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
 
-    test('reply from comment adds quote text', () => {
+    test('reply from comment adds quote text', async () => {
       const e = new CustomEvent('', {
         detail: {message: {message: 'quote text'}},
       });
       element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from comment replaces quote text', () => {
-      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> old quote text\n\n';
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from same comment preserves quote text', () => {
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element._handleMessageReply(e);
-      assert.equal(
-        element.$.replyDialog.draft,
-        '> quote text\n\n some draft text'
+      const dialog = await waitQueryAndAssert<GrReplyDialog>(
+        element,
+        '#replyDialog'
       );
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from top of page contains previous draft', () => {
-      const div = document.createElement('div');
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = {
-        target: div,
-        preventDefault: sinon.spy(),
-      } as unknown as MouseEvent;
-      element._handleReplyTap(e);
-      assert.equal(
-        element.$.replyDialog.draft,
-        '> quote text\n\n some draft text'
-      );
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      const openSpy = sinon.spy(dialog, 'open');
+      await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
+      assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
   });
 
@@ -2055,6 +2028,39 @@
     });
   });
 
+  test('patch range changed', () => {
+    element._patchRange = undefined;
+    element._change = createChangeViewChange();
+    element._change!.revisions = createRevisions(4);
+    element._change.current_revision = '1' as CommitId;
+    element._change = {...element._change};
+
+    const params = createAppElementChangeViewParams();
+
+    assert.isFalse(element.hasPatchRangeChanged(params));
+    assert.isFalse(element.hasPatchNumChanged(params));
+
+    params.basePatchNum = ParentPatchSetNum;
+    // undefined means navigate to latest patchset
+    params.patchNum = undefined;
+
+    element._patchRange = {
+      patchNum: 2 as RevisionPatchSetNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+
+    assert.isTrue(element.hasPatchRangeChanged(params));
+    assert.isTrue(element.hasPatchNumChanged(params));
+
+    element._patchRange = {
+      patchNum: 4 as RevisionPatchSetNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+
+    assert.isFalse(element.hasPatchRangeChanged(params));
+    assert.isFalse(element.hasPatchNumChanged(params));
+  });
+
   suite('_handleEditTap', () => {
     let fireEdit: () => void;
 
@@ -2090,7 +2096,7 @@
     test('no edit exists in revisions, non-latest patchset', async () => {
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
+        assert.equal(args.length, 6);
         assert.equal(args[1], 1 as PatchSetNum); // patchNum
         assert.equal(args[3], true); // opt_isEdit
         promise.resolve();
@@ -2107,7 +2113,7 @@
     test('no edit exists in revisions, latest patchset', async () => {
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
+        assert.equal(args.length, 6);
         // No patch should be specified when patchNum == latest.
         assert.isNotOk(args[1]); // patchNum
         assert.equal(args[3], true); // opt_isEdit
@@ -2131,7 +2137,7 @@
     navigateToChangeStub.restore();
     const promise = mockPromise();
     sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-      assert.equal(args.length, 2);
+      assert.equal(args.length, 6);
       assert.equal(args[1], 1 as PatchSetNum); // patchNum
       promise.resolve();
     });
@@ -2196,17 +2202,19 @@
     });
   });
 
-  test('_handleToggleStar called when star is tapped', () => {
+  test('_handleToggleStar called when star is tapped', async () => {
     element._change = {
       ...createChangeViewChange(),
       owner: {_account_id: 1 as AccountId},
       starred: false,
     };
     element._loggedIn = true;
-    const stub = sinon.stub(element, '_handleToggleStar');
-    flush();
+    await flush();
 
-    tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
+    const stub = sinon.stub(element, '_handleToggleStar');
+
+    const changeStar = queryAndAssert<GrChangeStar>(element, '#changeStar');
+    tap(queryAndAssert<HTMLButtonElement>(changeStar, 'button')!);
     assert.isTrue(stub.called);
   });
 
@@ -2249,6 +2257,8 @@
         appContext.reportingService,
         'changeFullyLoaded'
       );
+      // reset so reload is triggered
+      element._changeNum = undefined;
       element.params = {
         ...createAppElementChangeViewParams(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
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 5506bc7..95984b8 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
@@ -83,7 +83,10 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {preferences$} from '../../../services/user/user-model';
+import {
+  diffPreferences$,
+  sizeBarInChangeTable$,
+} from '../../../services/user/user-model';
 import {changeComments$} from '../../../services/comments/comments-model';
 import {Subject} from 'rxjs';
 import {takeUntil} from 'rxjs/operators';
@@ -317,6 +320,8 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly userService = appContext.userService;
+
   disconnected$ = new Subject();
 
   /** Called in disconnectedCallback. */
@@ -381,6 +386,16 @@
     diffViewMode$
       .pipe(takeUntil(this.disconnected$))
       .subscribe(diffView => (this.diffViewMode = diffView));
+    diffPreferences$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffPreferences => {
+        this.diffPrefs = diffPreferences;
+      });
+    sizeBarInChangeTable$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(sizeBarInChangeTable => {
+        this._showSizeBars = sizeBarInChangeTable;
+      });
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -479,16 +494,6 @@
         })
     );
 
-    promises.push(
-      this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      })
-    );
-
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
-      this._showSizeBars = !!prefs?.size_bar_in_change_table;
-    });
-
     return Promise.all(promises).then(() => {
       this._loading = false;
       this._detectChromiteButler();
@@ -1645,9 +1650,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
+    this.userService.getDiffPreferences();
   }
 
   /**
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 a496be5..962ccef 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
@@ -33,9 +33,11 @@
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {appContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
+import {getTriggerVotes, labelCompare} from '../../../utils/label-util';
 import {Execution} from '../../../constants/reporting';
 import {ChangeStatus} from '../../../constants/constants';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends LitElement {
@@ -50,8 +52,11 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flagsService = appContext.flagsService;
+
   static override get styles() {
     return [
+      fontStyles,
       css`
         .scoresTable {
           display: table;
@@ -72,26 +77,74 @@
         gr-label-score-row.no-access {
           display: none;
         }
+        .heading-3 {
+          padding-left: var(--spacing-xl);
+          margin-bottom: var(--spacing-m);
+          margin-top: var(--spacing-l);
+        }
+        .heading-3:first-of-type {
+          margin-top: 0;
+        }
       `,
     ];
   }
 
   override render() {
+    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+      return this.renderNewSubmitRequirements();
+    } else {
+      return this.renderOldSubmitRequirements();
+    }
+  }
+
+  private renderOldSubmitRequirements() {
     const labels = this._computeLabels();
+    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
+  }
+
+  private renderNewSubmitRequirements() {
+    return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
+    ${this.renderErrorMessages()}`;
+  }
+
+  private renderSubmitReqsLabels() {
+    const triggerVotes = getTriggerVotes(this.change);
+    const labels = this._computeLabels().filter(
+      label => !triggerVotes.includes(label.name)
+    );
+    if (!labels.length) return;
+    return html`<h3 class="heading-3">Submit requirements votes</h3>
+      ${this.renderLabels(labels)}`;
+  }
+
+  private renderTriggerVotes() {
+    const triggerVotes = getTriggerVotes(this.change);
+    const labels = this._computeLabels().filter(label =>
+      triggerVotes.includes(label.name)
+    );
+    if (!labels.length) return;
+    return html`<h3 class="heading-3">Trigger Votes</h3>
+      ${this.renderLabels(labels)}`;
+  }
+
+  private renderLabels(labels: Label[]) {
     const labelValues = this._computeColumns();
     return html`<div class="scoresTable">
-        ${labels.map(
-          label => html`<gr-label-score-row
-            class="${this.computeLabelAccessClass(label.name)}"
-            .label="${label}"
-            .name="${label.name}"
-            .labels="${this.change?.labels}"
-            .permittedLabels="${this.permittedLabels}"
-            .labelValues="${labelValues}"
-          ></gr-label-score-row>`
-        )}
-      </div>
-      <div
+      ${labels.map(
+        label => html`<gr-label-score-row
+          class="${this.computeLabelAccessClass(label.name)}"
+          .label="${label}"
+          .name="${label.name}"
+          .labels="${this.change?.labels}"
+          .permittedLabels="${this.permittedLabels}"
+          .labelValues="${labelValues}"
+        ></gr-label-score-row>`
+      )}
+    </div>`;
+  }
+
+  private renderErrorMessages() {
+    return html`<div
         class="mergedMessage"
         ?hidden=${this.change?.status !== ChangeStatus.MERGED}
       >
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index e16c073..c3acfb0 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -18,6 +18,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index 56fae87..087ee19 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -51,6 +51,9 @@
       border-bottom: 1px solid var(--border-color);
     }
   </style>
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <div class="header">
     <div id="showAllActivityToggleContainer" class="container">
       <template
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index e4703df..744db3b 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -35,6 +35,9 @@
   href?: string;
 
   @property()
+  label?: string;
+
+  @property()
   showSubmittableCheck = false;
 
   @property()
@@ -110,7 +113,12 @@
     const linkClass = this._computeLinkClass(change);
     return html`
       <div class="changeContainer">
-        <a href="${ifDefined(this.href)}" class="${linkClass}"><slot></slot></a>
+        <a
+          href="${ifDefined(this.href)}"
+          aria-label="${ifDefined(this.label)}"
+          class="${linkClass}"
+          ><slot></slot
+        ></a>
         ${this.showSubmittableCheck
           ? html`<span
               tabindex="-1"
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index bce4024..963c009 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -33,6 +33,7 @@
 import {appContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
@@ -146,6 +147,45 @@
       this.conflictingChanges.length,
       this.cherryPickChanges.length
     );
+
+    const sectionRenderers = [
+      this.renderRelationChain,
+      this.renderSubmittedTogether,
+      this.renderSameTopic,
+      this.renderMergeConflicts,
+      this.renderCherryPicks,
+    ];
+
+    let firstNonEmptySectionFound = false;
+    const sections = [];
+    for (const renderer of sectionRenderers) {
+      const section: TemplateResult<1> | undefined = renderer.call(
+        this,
+        !firstNonEmptySectionFound,
+        sectionSize
+      );
+      firstNonEmptySectionFound = firstNonEmptySectionFound || !!section;
+      sections.push(section);
+    }
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${sections}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  private renderRelationChain(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (this.relatedChanges.length === 0) {
+      return undefined;
+    }
     const relatedChangesMarkersPredicate = this.markersPredicateFactory(
       this.relatedChanges.length,
       this.relatedChanges.findIndex(relatedChange =>
@@ -158,17 +198,11 @@
       this.patchNum,
       this.relatedChanges
     );
-    let firstNonEmptySectionFound = false;
-    let isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.relatedChanges.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const relatedChangeSection = html` <section
-      id="relatedChanges"
-      ?hidden=${!this.relatedChanges.length}
-    >
+
+    return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
@@ -200,8 +234,19 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderSubmittedTogether(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
     const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    if (
+      !submittedTogetherChanges.length &&
+      !this.submittedTogether?.non_visible_changes
+    ) {
+      return undefined;
+    }
     const countNonVisibleChanges =
       this.submittedTogether?.non_visible_changes ?? 0;
     const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
@@ -211,19 +256,10 @@
       ),
       sectionSize(Section.SUBMITTED_TOGETHER)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound &&
-      (!!submittedTogetherChanges?.length ||
-        !!this.submittedTogether?.non_visible_changes);
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const submittedTogetherSection = html`<section
-      id="submittedTogether"
-      ?hidden=${!submittedTogetherChanges?.length &&
-      !this.submittedTogether?.non_visible_changes}
-    >
+    return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
@@ -239,14 +275,14 @@
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
+                .label="${this.renderChangeTitle(change)}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 .showSubmittableCheck=${true}
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
@@ -255,22 +291,25 @@
         (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
       </div>
     </section>`;
+  }
+
+  private renderSameTopic(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.sameTopicChanges?.length) {
+      return undefined;
+    }
 
     const sameTopicMarkersPredicate = this.markersPredicateFactory(
       this.sameTopicChanges.length,
       -1,
       sectionSize(Section.SAME_TOPIC)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const sameTopicSection = html`<section
-      id="sameTopic"
-      ?hidden=${!this.sameTopicChanges?.length}
-    >
+    return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
@@ -287,33 +326,35 @@
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
                 .change="${change}"
+                .label="${this.renderChangeTitle(change)}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderMergeConflicts(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.conflictingChanges?.length) {
+      return undefined;
+    }
     const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
       this.conflictingChanges.length,
       -1,
       sectionSize(Section.MERGE_CONFLICTS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const mergeConflictsSection = html`<section
-      id="mergeConflicts"
-      ?hidden=${!this.conflictingChanges?.length}
-    >
+    return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
@@ -340,22 +381,24 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderCherryPicks(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.cherryPickChanges.length) {
+      return undefined;
+    }
     const cherryPicksMarkersPredicate = this.markersPredicateFactory(
       this.cherryPickChanges.length,
       -1,
       sectionSize(Section.CHERRY_PICKS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const cherryPicksSection = html`<section
-      id="cherryPicks"
-      ?hidden=${!this.cherryPickChanges?.length}
-    >
+    return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirstNonEmpty})}"
+        class="${classMap({first: isFirst})}"
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
@@ -382,17 +425,17 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
-    return html`<gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param
-        name="change"
-        .value=${this.change}
-      ></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
-      ${mergeConflictsSection} ${cherryPicksSection}
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>`;
+  private renderChangeTitle(change: ChangeInfo) {
+    return `${change.project}: ${change.branch}: ${change.subject}`;
+  }
+
+  private renderChangeLine(change: ChangeInfo) {
+    const truncatedRepo = truncatePath(change.project, 2);
+    return html`<span class="truncatedRepo" .title="${change.project}"
+        >${truncatedRepo}</span
+      >: ${change.branch}: ${change.subject}`;
   }
 
   sectionSizeFactory(
@@ -691,18 +734,13 @@
       css`
         .title {
           color: var(--deemphasized-text-color);
-          padding-left: var(--metadata-horizontal-padding);
-        }
-        h4 {
           display: flex;
           align-self: flex-end;
+          margin-left: 20px;
         }
         gr-button {
           display: flex;
         }
-        h4 {
-          margin-left: 20px;
-        }
         gr-button iron-icon {
           color: inherit;
           --iron-icon-height: 18px;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index a6dc338f..d0b56fb 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -30,6 +30,7 @@
   createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
 import {
+  query,
   queryAndAssert,
   resetPlugins,
   stubRestApi,
@@ -227,11 +228,8 @@
         Promise.resolve(submittedTogether)
       );
       await element.reload();
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(relatedChanges!.classList.contains('first'));
+      const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
+      assert.notExists(relatedChanges);
       const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#submittedTogether'),
         'gr-related-collapse'
@@ -255,11 +253,11 @@
         'gr-related-collapse'
       );
       assert.isTrue(relatedChanges!.classList.contains('first'));
-      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
-        'gr-related-collapse'
+      const submittedTogetherSection = query<HTMLElement>(
+        element,
+        '#submittedTogether'
       );
-      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
+      assert.notExists(submittedTogetherSection);
       const cherryPicks = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#cherryPicks'),
         'gr-related-collapse'
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 b8932e5..5109b72 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
@@ -240,9 +240,6 @@
   @property({type: String, observer: '_draftChanged'})
   draft = '';
 
-  @property({type: String})
-  quote = '';
-
   @property({type: Object})
   filterReviewerSuggestion: (input: Suggestion) => boolean;
 
@@ -427,7 +424,13 @@
     super.disconnectedCallback();
   }
 
-  open(focusTarget?: FocusTarget) {
+  /**
+   * Note that this method is not actually *opening* the dialog. Opening and
+   * showing the dialog is dealt with by the overlay. This method is used by the
+   * change view for initializing the dialog after opening the overlay. Maybe it
+   * should be called `onOpened()` or `initialize()`?
+   */
+  open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
     this.changeService.fetchChangeUpdates(this.change).then(result => {
@@ -437,10 +440,9 @@
     });
 
     this._focusOn(focusTarget);
-    if (this.quote && this.quote.length) {
-      // If a reply quote has been provided, use it and clear the property.
-      this.draft = this.quote;
-      this.quote = '';
+    if (quote?.length) {
+      // If a reply quote has been provided, use it.
+      this.draft = quote;
     } else {
       // Otherwise, check for an unsaved draft in localstorage.
       this.draft = this._loadStoredDraft();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 45a55c1..4a8b996 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -341,7 +341,6 @@
             class="message newReplyDialog"
             autocomplete="on"
             placeholder="[[_messagePlaceholder]]"
-            fixed-position-dropdown=""
             monospace="true"
             disabled="{{disabled}}"
             rows="4"
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 5a11f47..cf31a4f 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
@@ -18,9 +18,11 @@
 import '../../../test/common-test-setup-karma';
 import './gr-reply-dialog';
 import {
+  addListenerForTest,
   mockPromise,
   queryAll,
   queryAndAssert,
+  stubRestApi,
   stubStorage,
 } from '../../../test/test-utils';
 import {
@@ -29,8 +31,6 @@
   SpecialFilePath,
 } from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
-import {addListenerForTest} from '../../../test/test-utils';
-import {stubRestApi} from '../../../test/test-utils';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
@@ -44,7 +44,7 @@
   pressAndReleaseKeyOn,
   tap,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {GrReplyDialog} from './gr-reply-dialog';
+import {FocusTarget, GrReplyDialog} from './gr-reply-dialog';
 import {
   AccountId,
   AccountInfo,
@@ -1317,11 +1317,9 @@
     const storedDraft = 'hello world';
     const quote = '> foo bar';
     getDraftCommentStub.returns({message: storedDraft});
-    element.quote = quote;
-    element.open();
+    element.open(FocusTarget.ANY, quote);
     assert.isFalse(getDraftCommentStub.called);
     assert.equal(element.draft, quote);
-    assert.isNotOk(element.quote);
   });
 
   test('updates stored draft on edits', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 157336d..6406e3c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -29,10 +29,11 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
-import {unique} from '../../../utils/common-util';
 import {
   extractAssociatedLabels,
   getAllUniqueApprovals,
+  getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   hasVotes,
   iconForStatus,
@@ -139,18 +140,9 @@
   }
 
   override render() {
-    let submit_requirements = orderSubmitRequirements(
-      this.change?.submit_requirements ?? []
-    ).filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE);
-
-    const hasNonLegacyRequirements = submit_requirements.some(
-      req => req.is_legacy === false
+    const submit_requirements = orderSubmitRequirements(
+      getRequirements(this.change)
     );
-    if (hasNonLegacyRequirements) {
-      submit_requirements = submit_requirements.filter(
-        req => req.is_legacy === false
-      );
-    }
 
     return html` <h3
         class="metadata-title heading-3"
@@ -167,25 +159,8 @@
           </tr>
         </thead>
         <tbody>
-          ${submit_requirements.map(
-            requirement => html`<tr
-              id="requirement-${charsOnly(requirement.name)}"
-            >
-              <td>${this.renderStatus(requirement.status)}</td>
-              <td class="name">
-                <gr-limited-text
-                  class="name"
-                  limit="25"
-                  .text="${requirement.name}"
-                ></gr-limited-text>
-              </td>
-              <td>
-                <div class="votes-cell">
-                  ${this.renderVotes(requirement)}
-                  ${this.renderChecks(requirement)}
-                </div>
-              </td>
-            </tr>`
+          ${submit_requirements.map(requirement =>
+            this.renderRequirement(requirement)
           )}
         </tbody>
       </table>
@@ -200,7 +175,38 @@
           ></gr-submit-requirement-hovercard>
         `
       )}
-      ${this.renderTriggerVotes(submit_requirements)}`;
+      ${this.renderTriggerVotes()}`;
+  }
+
+  renderRequirement(requirement: SubmitRequirementResultInfo) {
+    return html`
+      <tr id="requirement-${charsOnly(requirement.name)}">
+        <td>${this.renderStatus(requirement.status)}</td>
+        <td class="name">
+          <gr-limited-text
+            class="name"
+            limit="25"
+            .text="${requirement.name}"
+          ></gr-limited-text>
+        </td>
+        <td>
+          <gr-endpoint-decorator
+            class="votes-cell"
+            name="${`submit-requirement-${charsOnly(requirement.name)}`}"
+          >
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            <gr-endpoint-param
+              name="requirement"
+              .value=${requirement}
+            ></gr-endpoint-param>
+            ${this.renderVotes(requirement)}${this.renderChecks(requirement)}
+          </gr-endpoint-decorator>
+        </td>
+      </tr>
+    `;
   }
 
   renderStatus(status: SubmitRequirementStatus) {
@@ -275,15 +281,11 @@
     return;
   }
 
-  renderTriggerVotes(submitReqs: SubmitRequirementResultInfo[]) {
+  renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
-    const allLabels = Object.keys(labels);
-    const labelAssociatedWithSubmitReqs = submitReqs
-      .flatMap(req => extractAssociatedLabels(req))
-      .filter(unique);
-    const triggerVotes = allLabels
-      .filter(label => !labelAssociatedWithSubmitReqs.includes(label))
-      .filter(label => hasVotes(labels[label]));
+    const triggerVotes = getTriggerVotes(this.change).filter(label =>
+      hasVotes(labels[label])
+    );
     if (!triggerVotes.length) return;
     return html`<h3 class="metadata-title heading-3">Trigger Votes</h3>
       <section class="trigger-votes">
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index b8f2630..6b4006c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -256,11 +256,9 @@
   edit?: boolean;
   host?: string;
   messageHash?: string;
-  queryMap?: Map<string, string> | URLSearchParams;
   commentId?: UrlEncodedCommentId;
-
-  // TODO(TS): querystring isn't set anywhere, try to remove
-  querystring?: string;
+  forceReload?: boolean;
+  tab?: string;
 }
 
 export interface GenerateUrlRepoViewParameters {
@@ -612,7 +610,8 @@
     patchNum?: PatchSetNum,
     basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
-    messageHash?: string
+    messageHash?: string,
+    forceReload?: boolean
   ) {
     if (basePatchNum === ParentPatchSetNum) {
       basePatchNum = undefined;
@@ -628,6 +627,7 @@
       edit: isEdit,
       host: change.internalHost || undefined,
       messageHash,
+      forceReload,
     });
   },
 
@@ -649,17 +649,28 @@
    * @param redirect redirect to a change - if true, the current
    *     location (i.e. page which makes redirect) is not added to a history.
    *     I.e. back/forward buttons skip current location
-   *
+   * @param forceReload Some views are smart about how to handle the reload
+   *     of the view. In certain cases we want to force the view to reload
+   *     and re-render everything.
    */
+  // TODO(dhruvsri): move the arguments into one options object
   navigateToChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
     patchNum?: PatchSetNum,
     basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
-    redirect?: boolean
+    redirect?: boolean,
+    forceReload?: boolean
   ) {
     this._navigate(
-      this.getUrlForChange(change, patchNum, basePatchNum, isEdit),
+      this.getUrlForChange(
+        change,
+        patchNum,
+        basePatchNum,
+        isEdit,
+        undefined,
+        forceReload
+      ),
       redirect
     );
   },
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 f0cff85..65ac9df 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -530,17 +530,22 @@
       range = '/' + range;
     }
     let suffix = `${range}`;
-    if (params.querystring) {
-      suffix += '?' + params.querystring;
-    } else if (params.edit) {
-      suffix += ',edit';
+    let queryString = '';
+    if (params.forceReload) {
+      queryString = 'forceReload=true';
     }
-    if (params.messageHash) {
-      suffix += params.messageHash;
+    if (params.edit) {
+      suffix += ',edit';
     }
     if (params.commentId) {
       suffix = suffix + `/comments/${params.commentId}`;
     }
+    if (queryString) {
+      suffix += '?' + queryString;
+    }
+    if (params.messageHash) {
+      suffix += params.messageHash;
+    }
     if (params.project) {
       const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
@@ -1563,9 +1568,20 @@
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
-      queryMap: ctx.queryMap,
     };
 
+    if (ctx.queryMap.has('forceReload')) {
+      params.forceReload = true;
+      history.replaceState(
+        null,
+        '',
+        location.href.replace(/[?&]forceReload=true/, '')
+      );
+    }
+
+    const tab = ctx.queryMap.get('tab');
+    if (tab) params.tab = tab;
+
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
@@ -1661,13 +1677,24 @@
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    this._redirectOrNavigate({
+    const params: GenerateUrlChangeViewParameters = {
       project,
       changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
-    });
+      tab: ctx.queryMap.get('tab') ?? '',
+    };
+    if (ctx.queryMap.has('forceReload')) {
+      params.forceReload = true;
+      history.replaceState(
+        null,
+        '',
+        location.href.replace(/[?&]forceReload=true/, '')
+      );
+    }
+    this._redirectOrNavigate(params);
+
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index b91bf0c..7f1a40b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -312,28 +312,14 @@
         changeNum: '1234',
         project: 'test',
       };
-      const paramsWithQuery = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-        querystring: 'revert&foo=bar',
-      };
 
       assert.equal(element._generateUrl(params), '/c/test/+/1234');
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234?revert&foo=bar');
 
       params.patchNum = 10;
       assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-      paramsWithQuery.patchNum = 10;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/10?revert&foo=bar');
 
       params.basePatchNum = 5;
       assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-      paramsWithQuery.basePatchNum = 5;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/5..10?revert&foo=bar');
 
       params.messageHash = '#123';
       assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
@@ -1382,7 +1368,6 @@
             changeNum: 1234,
             basePatchNum: 4,
             patchNum: 7,
-            queryMap: new Map(),
           });
           assert.isFalse(redirectStub.called);
           assert.isTrue(normalizeRangeStub.called);
@@ -1549,6 +1534,7 @@
             null,
             3, // 3 Patch num
           ],
+          queryMap: new Map(),
         };
         const appParams = {
           project: 'foo/bar',
@@ -1556,6 +1542,7 @@
           view: GerritView.CHANGE,
           patchNum: 3,
           edit: true,
+          tab: '',
         };
 
         element._handleChangeEditRoute(ctx);
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.ts
similarity index 72%
rename from polygerrit-ui/app/elements/custom-dark-theme_test.js
rename to polygerrit-ui/app/elements/custom-dark-theme_test.ts
index 768a461..71e0740 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.ts
@@ -14,17 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../test/common-test-setup-karma.js';
-import {getComputedStyleValue} from '../utils/dom-util.js';
-import './gr-app.js';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {removeTheme} from '../styles/themes/dark-theme.js';
+import '../test/common-test-setup-karma';
+import {getComputedStyleValue} from '../utils/dom-util';
+import './gr-app';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrApp} from './gr-app';
 
 const basicFixture = fixtureFromElement('gr-app');
 
 suite('gr-app custom dark theme tests', () => {
-  let element;
+  let element: GrApp;
   setup(async () => {
     window.localStorage.setItem('dark-theme', 'true');
 
@@ -36,7 +35,6 @@
 
   teardown(() => {
     window.localStorage.removeItem('dark-theme');
-    removeTheme();
     // The app sends requests to server. This can lead to
     // unexpected gr-alert elements in document.body
     document.body.querySelectorAll('gr-alert').forEach(grAlert => {
@@ -45,19 +43,17 @@
   });
 
   test('should tried to load dark theme', () => {
-    assert.isTrue(
-        !!document.head.querySelector('#dark-theme')
-    );
+    assert.isTrue(!!document.head.querySelector('#dark-theme'));
   });
 
   test('applies the right theme', () => {
     assert.equal(
-        getComputedStyleValue('--header-background-color', element)
-            .toLowerCase(),
-        '#3c4043');
+      getComputedStyleValue('--header-background-color', element).toLowerCase(),
+      '#3c4043'
+    );
     assert.equal(
-        getComputedStyleValue('--footer-background-color', element)
-            .toLowerCase(),
-        '#3c4043');
+      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
+      '#3c4043'
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.ts
similarity index 69%
rename from polygerrit-ui/app/elements/custom-light-theme_test.js
rename to polygerrit-ui/app/elements/custom-light-theme_test.ts
index c6e9642..80a7cab 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.ts
@@ -14,21 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../test/common-test-setup-karma.js';
-import {getComputedStyleValue} from '../utils/dom-util.js';
-import './gr-app.js';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubRestApi} from '../test/test-utils.js';
+import '../test/common-test-setup-karma';
+import {getComputedStyleValue} from '../utils/dom-util';
+import './gr-app';
+import '../styles/themes/app-theme';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {stubRestApi} from '../test/test-utils';
+import {GrApp} from './gr-app';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-app');
 
 suite('gr-app custom light theme tests', () => {
-  let element;
+  let element: GrApp;
   setup(async () => {
     window.localStorage.removeItem('dark-theme');
-    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
-    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+    stubRestApi('getAccount').returns(
+      Promise.resolve(createAccountDetailWithId())
+    );
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
@@ -52,12 +59,12 @@
 
   test('applies the right theme', () => {
     assert.equal(
-        getComputedStyleValue('--header-background-color', element)
-            .toLowerCase(),
-        '#f1f3f4');
+      getComputedStyleValue('--header-background-color', element).toLowerCase(),
+      '#f1f3f4'
+    );
     assert.equal(
-        getComputedStyleValue('--footer-background-color', element)
-            .toLowerCase(),
-        'transparent');
+      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
+      'transparent'
+    );
   });
 });
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 f5072b9..a6176e1 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
@@ -41,7 +41,6 @@
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -109,10 +108,7 @@
   constructor() {
     super();
     this.restApiService.getPreferences().then(prefs => {
-      if (
-        !prefs?.disable_token_highlighting &&
-        appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
-      ) {
+      if (!prefs?.disable_token_highlighting) {
         this.layers = [new TokenHighlightLayer(this)];
       }
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index fc22a58..7e7e507 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -70,11 +70,11 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: PathToCommentsInfoMap | undefined,
-    robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
-    drafts: {[path: string]: DraftInfo[]} | undefined,
-    portedComments: PathToCommentsInfoMap | undefined,
-    portedDrafts: PathToCommentsInfoMap | undefined
+    comments?: PathToCommentsInfoMap,
+    robotComments?: {[path: string]: RobotCommentInfo[]},
+    drafts?: {[path: string]: DraftInfo[]},
+    portedComments?: PathToCommentsInfoMap,
+    portedDrafts?: PathToCommentsInfoMap
   ) {
     this._comments = addPath(comments);
     this._robotComments = addPath(robotComments);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index de7d007..54b2450f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -198,7 +198,6 @@
       element,
     } = this.findTokenAncestor(e?.target);
     if (!newHighlight || newHighlight === this.currentHighlight) return;
-    if (this.countOccurrences(newHighlight) <= 1) return;
     this.hoveredElement = element;
     this.updateTokenTask = debounce(
       this.updateTokenTask,
@@ -247,13 +246,6 @@
     return this.findTokenAncestor(el.parentElement);
   }
 
-  countOccurrences(token: string | undefined) {
-    if (!token) return 0;
-    const linesLeft = this.tokenToLinesLeft.get(token);
-    const linesRight = this.tokenToLinesRight.get(token);
-    return (linesLeft?.size ?? 0) + (linesRight?.size ?? 0);
-  }
-
   private updateTokenHighlight(
     newHighlight: string | undefined,
     newLineNumber: number,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
index 2993d35..a0670b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -290,6 +290,34 @@
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
     });
 
+    test('triggers listener on token with single occurrence', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('a tokenWithSingleOccurence');
+      const line2 = createLine('can be highlighted', 2);
+      annotate(line1);
+      annotate(line2, Side.RIGHT, 2);
+      const tokenNode = queryAndAssert(line1, '.tk-tokenWithSingleOccurence');
+      assert.isTrue(tokenNode.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(tokenNode),
+        tokenNode
+      );
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'tokenWithSingleOccurence',
+        side: Side.RIGHT,
+        element: tokenNode,
+        range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
+      });
+
+      MockInteractions.click(container);
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+    });
+
     test('clicking clears highlight', async () => {
       const clock = sinon.useFakeTimers();
       const line1 = createLine('two words');
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 a828b9c..025e477 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
@@ -336,9 +336,7 @@
     const preferencesPromise = appContext.restApiService.getPreferences();
     await getPluginLoader().awaitPluginsLoaded();
     const prefs = await preferencesPromise;
-    const enableTokenHighlight =
-      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING) &&
-      !prefs?.disable_token_highlighting;
+    const enableTokenHighlight = !prefs?.disable_token_highlighting;
 
     assertIsDefined(this.path, 'path');
     this._layers = this.getLayers(this.path, enableTokenHighlight);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 8901636..6facdca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1304,7 +1304,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1358,7 +1358,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
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 5a8c55d..9f38655b 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
@@ -87,18 +87,16 @@
     });
   }
 
-  _handleSaveDiffPreferences() {
+  async _handleSaveDiffPreferences() {
     this.diffPrefs = this._editableDiffPrefs;
-    this.$.diffPreferences.save().then(() => {
-      this.dispatchEvent(
-        new CustomEvent('reload-diff-preference', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-
-      this.$.diffPrefsOverlay.close();
-    });
+    await this.$.diffPreferences.save();
+    this.dispatchEvent(
+      new CustomEvent('reload-diff-preference', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+    this.$.diffPrefsOverlay.close();
   }
 }
 
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 d892c79..0ffe61f 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
@@ -98,7 +98,7 @@
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
-import {AppElementParams} from '../../gr-app-types';
+import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
 import {EventType, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
@@ -109,8 +109,11 @@
 import {changeComments$} from '../../../services/comments/comments-model';
 import {takeUntil} from 'rxjs/operators';
 import {Subject} from 'rxjs';
-import {preferences$} from '../../../services/user/user-model';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {
+  preferences$,
+  diffPreferences$,
+} from '../../../services/user/user-model';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
@@ -365,10 +368,6 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
-    // all. We need the service to manage the model, but we are not making any
-    // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(appContext.userService);
 
     changeComments$
       .pipe(takeUntil(this.disconnected$))
@@ -379,6 +378,11 @@
     preferences$.pipe(takeUntil(this.disconnected$)).subscribe(preferences => {
       this._userPrefs = preferences;
     });
+    diffPreferences$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffPreferences => {
+        this._prefs = diffPreferences;
+      });
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
@@ -498,12 +502,6 @@
       });
   }
 
-  _getDiffPreferences() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      this._prefs = prefs;
-    });
-  }
-
   _getPreferences() {
     return this.restApiService.getPreferences();
   }
@@ -1016,17 +1014,36 @@
     );
   }
 
+  private isSameDiffLoaded(value: AppElementDiffViewParam) {
+    return (
+      this._patchRange?.basePatchNum === value.basePatchNum &&
+      this._patchRange?.patchNum === value.patchNum &&
+      this._path === value.path
+    );
+  }
+
   _paramsChanged(value: AppElementParams) {
     if (value.view !== GerritView.DIFF) {
       return;
     }
 
+    // The diff view is kept in the background once created. If the user
+    // scrolls in the change page, the scrolling is reflected in the diff view
+    // as well, which means the diff is scrolled to a random position based
+    // on how much the change view was scrolled.
+    // Hence, reset the scroll position here.
+    document.documentElement.scrollTop = 0;
+
     // Everything in the diff view is tied to the change. It seems better to
     // force the re-creation of the diff view when the change number changes.
     const changeChanged = this._changeNum !== value.changeNum;
     if (this._changeNum !== undefined && changeChanged) {
       fireEvent(this, EventType.RECREATE_DIFF_VIEW);
       return;
+    } else if (this._changeNum !== undefined && this.isSameDiffLoaded(value)) {
+      // changeNum has not changed, so check if there are changes in patchRange
+      // path. If no changes then we can simply render the view as is.
+      return;
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
@@ -1055,8 +1072,6 @@
 
     const promises: Promise<unknown>[] = [];
 
-    promises.push(this._getDiffPreferences());
-
     if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
 
     if (!this._changeComments) this._loadComments(value.patchNum);
@@ -1709,7 +1724,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this._getDiffPreferences();
+    this.userService.getDiffPreferences();
   }
 
   _computeCanEdit(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index cc9aac0..46ec5d0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -331,6 +331,7 @@
           assert.isFalse(getDiffChangeDetailStub.called);
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
+          sinon.stub(element, '_pathChanged');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.spy(element, '_paramsChanged');
           element._change = undefined;
@@ -993,7 +994,9 @@
     });
 
     suite('diff prefs hidden', () => {
-      test('whenlogged out', () => {
+      test('when no prefs or logged out', () => {
+        element._prefs = undefined;
+        element.disableDiffPrefs = false;
         element._loggedIn = false;
         flush();
         assert.isTrue(element.$.diffPrefsContainer.hidden);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 7efd2f8..6003a2f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -950,7 +950,7 @@
       // Safari is not binding newly created comment-thread
       // with the slot somehow, replace itself will rebind it
       // @see Issue 11182
-      if (lastEl && lastEl.replaceWith) {
+      if (isSafari() && lastEl && lastEl.replaceWith) {
         lastEl.replaceWith(lastEl);
       }
 
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 857ffa2..501f688 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
@@ -14,14 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-a11y-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-patch-range-select_html';
-import {pluralize} from '../../../utils/string-util';
+import {convertToString, pluralize} from '../../../utils/string-util';
 import {appContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
@@ -33,7 +28,6 @@
   PatchSet,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
@@ -44,7 +38,6 @@
   Timestamp,
 } from '../../../types/common';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {
   DropdownItem,
@@ -52,6 +45,11 @@
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GeneratedWebLink} from '../../core/gr-navigation/gr-navigation';
+import {EditRevisionInfo} from '../../../types/types';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -68,10 +66,13 @@
   meta_b: GeneratedWebLink[];
 }
 
-export interface GrPatchRangeSelect {
-  $: {
-    patchNumDropdown: GrDropdownList;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'value-change': DropDownValueChangeEvent;
+  }
+  interface HTMLElementTagNameMap {
+    'gr-patch-range-select': GrPatchRangeSelect;
+  }
 }
 
 /**
@@ -83,30 +84,13 @@
  * @property {string} basePatchNum
  */
 @customElement('gr-patch-range-select')
-export class GrPatchRangeSelect extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPatchRangeSelect extends LitElement {
+  @query('#patchNumDropdown')
+  patchNumDropdown?: GrDropdownList;
 
   @property({type: Array})
   availablePatches?: PatchSet[];
 
-  @property({
-    type: Object,
-    computed:
-      '_computeBaseDropdownContent(availablePatches, patchNum,' +
-      '_sortedRevisions, changeComments, revisionInfo)',
-  })
-  _baseDropdownContent?: DropdownItem[];
-
-  @property({
-    type: Object,
-    computed:
-      '_computePatchDropdownContent(availablePatches,' +
-      'basePatchNum, _sortedRevisions, changeComments)',
-  })
-  _patchDropdownContent?: DropdownItem[];
-
   @property({type: String})
   changeNum?: string;
 
@@ -129,13 +113,106 @@
   revisionInfo?: RevisionInfoClass;
 
   @property({type: Array})
-  _sortedRevisions?: RevisionInfo[];
+  @state()
+  protected sortedRevisions?: RevisionInfo[];
 
   private readonly reporting: ReportingService = appContext.reportingService;
 
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: flex;
+        }
+        select {
+          max-width: 15em;
+        }
+        .arrow {
+          color: var(--deemphasized-text-color);
+          margin: 0 var(--spacing-m);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--deemphasized-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        @media screen and (max-width: 50em) {
+          .filesWeblinks {
+            display: none;
+          }
+          gr-dropdown-list {
+            --native-select-style: {
+              max-width: 5.25em;
+            }
+          }
+        }
+      `,
+    ];
+  }
+
+  private renderWeblinks(fileLink?: GeneratedWebLink[]) {
+    if (!fileLink) return;
+
+    return html`<span class="filesWeblinks">
+      ${fileLink.map(
+        weblink => html`
+          <a target="_blank" rel="noopener" href="${weblink.url}">
+            ${weblink.name}
+          </a>
+        `
+      )}</span
+    > `;
+  }
+
+  override render() {
+    return html`
+      <h3 class="assistive-tech-only">Patchset Range Selection</h3>
+      <span class="patchRange" aria-label="patch range starts with">
+        <gr-dropdown-list
+          id="basePatchDropdown"
+          .value="${convertToString(this.basePatchNum)}"
+          .items="${this._computeBaseDropdownContent(
+            this.availablePatches,
+            this.patchNum,
+            this.sortedRevisions,
+            this.changeComments,
+            this.revisionInfo
+          )}"
+          @value-change=${this._handlePatchChange}
+        >
+        </gr-dropdown-list>
+      </span>
+      ${this.renderWeblinks(this.filesWeblinks?.meta_a)}
+      <span aria-hidden="true" class="arrow">→</span>
+      <span class="patchRange" aria-label="patch range ends with">
+        <gr-dropdown-list
+          id="patchNumDropdown"
+          .value="${convertToString(this.patchNum)}"
+          .items="${this._computePatchDropdownContent(
+            this.availablePatches,
+            this.basePatchNum,
+            this.sortedRevisions,
+            this.changeComments
+          )}"
+          @value-change=${this._handlePatchChange}
+        >
+        </gr-dropdown-list>
+        ${this.renderWeblinks(this.filesWeblinks?.meta_b)}
+      </span>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('revisions')) {
+      this._updateSortedRevisions(this.revisions);
+    }
+  }
+
+  _updateSortedRevisions(revisions?: RevisionInfo[]) {
+    if (!revisions) return;
+    this.sortedRevisions = sortRevisions(Object.values(revisions));
   }
 
   _getShaForPatch(patch: PatchSet) {
@@ -145,19 +222,19 @@
   _computeBaseDropdownContent(
     availablePatches?: PatchSet[],
     patchNum?: PatchSetNum,
-    _sortedRevisions?: RevisionInfo[],
+    sortedRevisions?: (RevisionInfo | EditRevisionInfo)[],
     changeComments?: ChangeComments,
     revisionInfo?: RevisionInfoClass
-  ): DropdownItem[] | undefined {
+  ): DropdownItem[] {
     // Polymer 2: check for undefined
     if (
       availablePatches === undefined ||
       patchNum === undefined ||
-      _sortedRevisions === undefined ||
+      sortedRevisions === undefined ||
       changeComments === undefined ||
       revisionInfo === undefined
     ) {
-      return undefined;
+      return [];
     }
 
     const parentCounts = revisionInfo.getParentCountMap();
@@ -173,7 +250,7 @@
       const entry: DropdownItem = this._createDropdownEntry(
         basePatchNum,
         'Patchset ',
-        _sortedRevisions,
+        sortedRevisions,
         changeComments,
         this._getShaForPatch(basePatch)
       );
@@ -182,7 +259,7 @@
         disabled: this._computeLeftDisabled(
           basePatch.num,
           patchNum,
-          _sortedRevisions
+          sortedRevisions
         ),
       });
     }
@@ -208,7 +285,7 @@
   _computeMobileText(
     patchNum: PatchSetNum,
     changeComments: ChangeComments,
-    revisions: RevisionInfo[]
+    revisions: (RevisionInfo | EditRevisionInfo)[]
   ) {
     return (
       `${patchNum}` +
@@ -220,17 +297,17 @@
   _computePatchDropdownContent(
     availablePatches?: PatchSet[],
     basePatchNum?: BasePatchSetNum,
-    _sortedRevisions?: RevisionInfo[],
+    sortedRevisions?: (RevisionInfo | EditRevisionInfo)[],
     changeComments?: ChangeComments
-  ): DropdownItem[] | undefined {
+  ): DropdownItem[] {
     // Polymer 2: check for undefined
     if (
       availablePatches === undefined ||
       basePatchNum === undefined ||
-      _sortedRevisions === undefined ||
+      sortedRevisions === undefined ||
       changeComments === undefined
     ) {
-      return undefined;
+      return [];
     }
 
     const dropdownContent: DropdownItem[] = [];
@@ -239,7 +316,7 @@
       const entry = this._createDropdownEntry(
         patchNum,
         patchNum === 'edit' ? '' : 'Patchset ',
-        _sortedRevisions,
+        sortedRevisions,
         changeComments,
         this._getShaForPatch(patch)
       );
@@ -248,7 +325,7 @@
         disabled: this._computeRightDisabled(
           basePatchNum,
           patchNum,
-          _sortedRevisions
+          sortedRevisions
         ),
       });
     }
@@ -271,7 +348,7 @@
   _createDropdownEntry(
     patchNum: PatchSetNum,
     prefix: string,
-    sortedRevisions: RevisionInfo[],
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[],
     changeComments: ChangeComments,
     sha: string
   ) {
@@ -296,15 +373,6 @@
     return entry;
   }
 
-  @observe('revisions.*')
-  _updateSortedRevisions(
-    revisionsRecord: PolymerDeepPropertyChange<RevisionInfo[], RevisionInfo[]>
-  ) {
-    const revisions = revisionsRecord.base;
-    if (!revisions) return;
-    this._sortedRevisions = sortRevisions(Object.values(revisions));
-  }
-
   /**
    * The basePatchNum should always be <= patchNum -- because sortedRevisions
    * is sorted in reverse order (higher patchset nums first), invalid base
@@ -316,7 +384,7 @@
   _computeLeftDisabled(
     basePatchNum: PatchSetNum,
     patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
   ): boolean {
     return (
       findSortedIndex(basePatchNum, sortedRevisions) <=
@@ -341,7 +409,7 @@
   _computeRightDisabled(
     basePatchNum: PatchSetNum,
     patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
   ): boolean {
     if (basePatchNum === ParentPatchSetNum) {
       return false;
@@ -401,7 +469,7 @@
   }
 
   _computePatchSetDescription(
-    revisions: RevisionInfo[],
+    revisions: (RevisionInfo | EditRevisionInfo)[],
     patchNum: PatchSetNum,
     addFrontSpace?: boolean
   ) {
@@ -413,7 +481,7 @@
   }
 
   _computePatchSetDate(
-    revisions: RevisionInfo[],
+    revisions: (RevisionInfo | EditRevisionInfo)[],
     patchNum: PatchSetNum
   ): Timestamp | undefined {
     const rev = getRevisionByPatchNum(revisions, patchNum);
@@ -429,10 +497,10 @@
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
     };
-    const target = (dom(e) as EventApi).localTarget;
+    const target = e.target;
     const patchSetValue = convertToPatchSetNum(e.detail.value)!;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
-    if (target === this.$.patchNumDropdown) {
+    if (target === this.patchNumDropdown) {
       if (detail.patchNum === e.detail.value) return;
       this.reporting.reportInteraction('right-patchset-changed', {
         previous: detail.patchNum,
@@ -460,9 +528,3 @@
     );
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-patch-range-select': GrPatchRangeSelect;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
deleted file mode 100644
index 26944a4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-    }
-    select {
-      max-width: 15em;
-    }
-    .arrow {
-      color: var(--deemphasized-text-color);
-      margin: 0 var(--spacing-m);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--deemphasized-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    @media screen and (max-width: 50em) {
-      .filesWeblinks {
-        display: none;
-      }
-      gr-dropdown-list {
-        --native-select-style: {
-          max-width: 5.25em;
-        }
-      }
-    }
-  </style>
-  <h3 class="assistive-tech-only">Patchset Range Selection</h3>
-  <span class="patchRange" aria-label="patch range starts with">
-    <gr-dropdown-list
-      id="basePatchDropdown"
-      value="[[basePatchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_baseDropdownContent]]"
-    >
-    </gr-dropdown-list>
-  </span>
-  <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
-    <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
-        >[[weblink.name]]</a
-      >
-    </template>
-  </span>
-  <span aria-hidden="true" class="arrow">→</span>
-  <span class="patchRange" aria-label="patch range ends with">
-    <gr-dropdown-list
-      id="patchNumDropdown"
-      value="[[patchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_patchDropdownContent]]"
-    >
-    </gr-dropdown-list>
-    <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
-      <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
deleted file mode 100644
index 28ebbac..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ /dev/null
@@ -1,395 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../../shared/revision-info/revision-info.js';
-import './gr-patch-range-select.js';
-import '../../../test/mocks/comment-api.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const commentApiMockElement = createCommentApiMockWithTemplateElement(
-    'gr-patch-range-select-comment-api-mock', html`
-    <gr-patch-range-select id="patchRange" auto
-        change-comments="[[_changeComments]]"></gr-patch-range-select>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-`);
-
-const basicFixture = fixtureFromElement(commentApiMockElement.is);
-
-suite('gr-patch-range-select tests', () => {
-  let element;
-
-  let commentApiWrapper;
-
-  function getInfo(revisions) {
-    const revisionObj = {};
-    for (let i = 0; i < revisions.length; i++) {
-      revisionObj[i] = revisions[i];
-    }
-    return new RevisionInfo({revisions: revisionObj});
-  }
-
-  setup(() => {
-    stubRestApi('getDiffComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
-    // Element must be wrapped in an element with direct access to the
-    // comment API.
-    commentApiWrapper = basicFixture.instantiate();
-    element = commentApiWrapper.$.patchRange;
-
-    // Stub methods on the changeComments object after changeComments has
-    // been initialized.
-    element.changeComments = new ChangeComments();
-  });
-
-  test('enabled/disabled options', () => {
-    const patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 3,
-    };
-    const sortedRevisions = [
-      {_number: 3},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2},
-      {_number: 1},
-    ];
-    for (const patchNum of ['1', '2', '3']) {
-      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
-          patchNum, sortedRevisions));
-    }
-    for (const basePatchNum of ['1', '2']) {
-      assert.isFalse(element._computeLeftDisabled(basePatchNum,
-          patchRange.patchNum, sortedRevisions));
-    }
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
-
-    patchRange.basePatchNum = EditPatchSetNum;
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
-        sortedRevisions));
-    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-        EditPatchSetNum, sortedRevisions));
-  });
-
-  test('_computeBaseDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const revisions = [
-      {
-        commit: {parents: []},
-        _number: 2,
-        description: 'description',
-      },
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(revisions);
-    const patchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-    const expectedResult = [
-      {
-        disabled: true,
-        triggerText: 'Patchset edit',
-        text: 'Patchset edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-      {
-        text: 'Base',
-        value: 'PARENT',
-      },
-    ];
-    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-        patchNum, sortedRevisions, element.changeComments,
-        element.revisionInfo),
-    expectedResult);
-  });
-
-  test('_computeBaseDropdownContent called when patchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    sinon.stub(element, '_computeBaseDropdownContent');
-
-    // Should be recomputed for each available patch
-    element.set('patchNum', 1);
-    assert.equal(element._computeBaseDropdownContent.callCount, 1);
-  });
-
-  test('_computeBaseDropdownContent called when changeComments update',
-      async () => {
-        element.revisions = [
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-        ];
-        element.revisionInfo = getInfo(element.revisions);
-        element.availablePatches = [
-          {num: 'edit', sha: '1'},
-          {num: 3, sha: '2'},
-          {num: 2, sha: '3'},
-          {num: 1, sha: '4'},
-        ];
-        element.patchNum = 2;
-        element.basePatchNum = 'PARENT';
-        await flush();
-
-        // Should be recomputed for each available patch
-        sinon.stub(element, '_computeBaseDropdownContent');
-        assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        element.changeComments = new ChangeComments();
-        await flush();
-        assert.equal(element._computeBaseDropdownContent.callCount, 1);
-      });
-
-  test('_computePatchDropdownContent called when basePatchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    element.set('basePatchNum', 1);
-    assert.equal(element._computePatchDropdownContent.callCount, 1);
-  });
-
-  test('_computePatchDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const basePatchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-
-    const expectedResult = [
-      {
-        disabled: false,
-        triggerText: 'edit',
-        text: 'edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-    ];
-
-    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-        basePatchNum, sortedRevisions, element.changeComments),
-    expectedResult);
-  });
-
-  test('filesWeblinks', () => {
-    element.filesWeblinks = {
-      meta_a: [
-        {
-          name: 'foo',
-          url: 'f.oo',
-        },
-      ],
-      meta_b: [
-        {
-          name: 'bar',
-          url: 'ba.r',
-        },
-      ],
-    };
-    flush();
-    const domApi = dom(element.root);
-    assert.equal(
-        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
-    assert.equal(
-        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
-  });
-
-  test('_computePatchSetCommentsString', () => {
-    // Test string with unresolved comments.
-    const comments = {
-      foo: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        unresolved: true,
-        updated: '2017-10-11 20:48:40.000000000',
-      }],
-      bar: [
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-12 20:48:40.000000000',
-        },
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-13 20:48:40.000000000',
-        },
-      ],
-      abc: [],
-      // Patchset level comment does not contribute to the count
-      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        unresolved: true,
-        updated: '2017-10-11 20:48:40.000000000',
-      }],
-    };
-    element.changeComments = new ChangeComments(comments);
-
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (3 comments, 1 unresolved)');
-
-    // Test string with no unresolved comments.
-    delete element.changeComments._comments['foo'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (2 comments)');
-
-    // Test string with no comments.
-    delete element.changeComments._comments['bar'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), '');
-  });
-
-  test('patch-range-change fires', () => {
-    const handler = sinon.stub();
-    element.basePatchNum = 1;
-    element.patchNum = 3;
-    element.addEventListener('patch-range-change', handler);
-
-    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
-    assert.isTrue(handler.calledOnce);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 2, patchNum: 3});
-
-    // BasePatchNum should not have changed, due to one-way data binding.
-    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 1, patchNum: 'edit'});
-  });
-});
-
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
new file mode 100644
index 0000000..a47b685
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -0,0 +1,491 @@
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../gr-comment-api/gr-comment-api';
+import '../../shared/revision-info/revision-info';
+import './gr-patch-range-select';
+import {GrPatchRangeSelect} from './gr-patch-range-select';
+import '../../../test/mocks/comment-api';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {stubRestApi} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  EditPatchSetNum,
+  PatchSetNum,
+  RevisionInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+  PathToCommentsInfoMap,
+} from '../../../types/common';
+import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
+import {SpecialFilePath} from '../../../constants/constants';
+import {
+  createEditRevision,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {PatchSet} from '../../../utils/patch-set-util';
+import {
+  DropdownItem,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {queryAndAssert} from '../../../test/test-utils';
+
+const basicFixture = fixtureFromElement('gr-patch-range-select');
+
+type RevIdToRevisionInfo = {
+  [revisionId: string]: RevisionInfo | EditRevisionInfo;
+};
+
+suite('gr-patch-range-select tests', () => {
+  let element: GrPatchRangeSelect;
+
+  function getInfo(revisions: RevisionInfo[]) {
+    const revisionObj: Partial<RevIdToRevisionInfo> = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
+    }
+    return new RevisionInfoClass({revisions: revisionObj} as ParsedChangeInfo);
+  }
+
+  setup(async () => {
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+
+    // Element must be wrapped in an element with direct access to the
+    // comment API.
+    element = basicFixture.instantiate();
+
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    element.changeComments = new ChangeComments();
+    await element.updateComplete;
+  });
+
+  test('enabled/disabled options', () => {
+    const patchRange = {
+      basePatchNum: 'PARENT' as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    const sortedRevisions = [
+      createRevision(3) as RevisionInfo,
+      createEditRevision(2) as EditRevisionInfo,
+      createRevision(2) as RevisionInfo,
+      createRevision(1) as RevisionInfo,
+    ];
+    for (const patchNum of [1, 2, 3]) {
+      assert.isFalse(
+        element._computeRightDisabled(
+          patchRange.basePatchNum,
+          patchNum as PatchSetNum,
+          sortedRevisions
+        )
+      );
+    }
+    for (const basePatchNum of [1, 2]) {
+      assert.isFalse(
+        element._computeLeftDisabled(
+          basePatchNum as PatchSetNum,
+          patchRange.patchNum,
+          sortedRevisions
+        )
+      );
+    }
+    assert.isTrue(
+      element._computeLeftDisabled(3 as PatchSetNum, patchRange.patchNum, [])
+    );
+
+    patchRange.basePatchNum = EditPatchSetNum;
+    assert.isTrue(
+      element._computeLeftDisabled(
+        3 as PatchSetNum,
+        patchRange.patchNum,
+        sortedRevisions
+      )
+    );
+    assert.isTrue(
+      element._computeRightDisabled(
+        patchRange.basePatchNum,
+        1 as PatchSetNum,
+        sortedRevisions
+      )
+    );
+    assert.isTrue(
+      element._computeRightDisabled(
+        patchRange.basePatchNum,
+        2 as PatchSetNum,
+        sortedRevisions
+      )
+    );
+    assert.isFalse(
+      element._computeRightDisabled(
+        patchRange.basePatchNum,
+        3 as PatchSetNum,
+        sortedRevisions
+      )
+    );
+    assert.isTrue(
+      element._computeRightDisabled(
+        patchRange.basePatchNum,
+        EditPatchSetNum,
+        sortedRevisions
+      )
+    );
+  });
+
+  test('_computeBaseDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    const revisions: RevisionInfo[] = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(revisions);
+    const sortedRevisions = [
+      createRevision(3) as RevisionInfo,
+      createEditRevision(2) as EditRevisionInfo,
+      createRevision(2) as RevisionInfo,
+      createRevision(1) as RevisionInfo,
+    ];
+    const expectedResult: DropdownItem[] = [
+      {
+        disabled: true,
+        triggerText: 'Patchset edit',
+        text: 'Patchset edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2',
+        bottomText: '',
+        value: 2,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        text: 'Base',
+        value: 'PARENT',
+      } as DropdownItem,
+    ];
+    assert.deepEqual(
+      element._computeBaseDropdownContent(
+        availablePatches,
+        1 as PatchSetNum,
+        sortedRevisions,
+        element.changeComments,
+        element.revisionInfo
+      ),
+      expectedResult
+    );
+  });
+
+  test('_computeBaseDropdownContent called when patchNum updates', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'} as PatchSet,
+      {num: 2, sha: '2'} as PatchSet,
+      {num: 3, sha: '3'} as PatchSet,
+      {num: 'edit', sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    const baseDropDownStub = sinon.stub(element, '_computeBaseDropdownContent');
+
+    // Should be recomputed for each available patch
+    element.patchNum = 1 as PatchSetNum;
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('_computeBaseDropdownContent called when changeComments update', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    // Should be recomputed for each available patch
+    const baseDropDownStub = sinon.stub(element, '_computeBaseDropdownContent');
+    assert.equal(baseDropDownStub.callCount, 0);
+    element.changeComments = new ChangeComments();
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('_computePatchDropdownContent called when basePatchNum updates', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'} as PatchSet,
+      {num: 2, sha: '2'} as PatchSet,
+      {num: 3, sha: '3'} as PatchSet,
+      {num: 'edit', sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    // Should be recomputed for each available patch
+    const baseDropDownStub = sinon.stub(
+      element,
+      '_computePatchDropdownContent'
+    );
+    element.basePatchNum = 1 as BasePatchSetNum;
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('_computePatchDropdownContent', () => {
+    const availablePatches: PatchSet[] = [
+      {num: 'edit', sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    const basePatchNum = 1;
+    const sortedRevisions = [
+      createRevision(3) as RevisionInfo,
+      createEditRevision(2) as EditRevisionInfo,
+      createRevision(2, 'description') as RevisionInfo,
+      createRevision(1) as RevisionInfo,
+    ];
+
+    const expectedResult: DropdownItem[] = [
+      {
+        disabled: false,
+        triggerText: 'edit',
+        text: 'edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: false,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+    ];
+
+    assert.deepEqual(
+      element._computePatchDropdownContent(
+        availablePatches,
+        basePatchNum as BasePatchSetNum,
+        sortedRevisions,
+        element.changeComments
+      ),
+      expectedResult
+    );
+  });
+
+  test('filesWeblinks', async () => {
+    element.filesWeblinks = {
+      meta_a: [
+        {
+          name: 'foo',
+          url: 'f.oo',
+        },
+      ],
+      meta_b: [
+        {
+          name: 'bar',
+          url: 'ba.r',
+        },
+      ],
+    };
+    await element.updateComplete;
+    assert.equal(
+      queryAndAssert(element, 'a[href="f.oo"]').textContent!.trim(),
+      'foo'
+    );
+    assert.equal(
+      queryAndAssert(element, 'a[href="ba.r"]').textContent!.trim(),
+      'bar'
+    );
+  });
+
+  test('_computePatchSetCommentsString', () => {
+    // Test string with unresolved comments.
+    const comments: PathToCommentsInfoMap = {
+      foo: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          unresolved: true,
+          updated: '2017-10-11 20:48:40.000000000' as Timestamp,
+        },
+      ],
+      bar: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          updated: '2017-10-12 20:48:40.000000000' as Timestamp,
+        },
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          updated: '2017-10-13 20:48:40.000000000' as Timestamp,
+        },
+      ],
+      abc: [],
+      // Patchset level comment does not contribute to the count
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          unresolved: true,
+          updated: '2017-10-11 20:48:40.000000000' as Timestamp,
+        },
+      ],
+    };
+    element.changeComments = new ChangeComments(comments);
+
+    assert.equal(
+      element._computePatchSetCommentsString(
+        element.changeComments,
+        1 as PatchSetNum
+      ),
+      ' (3 comments, 1 unresolved)'
+    );
+
+    // Test string with no unresolved comments.
+    delete comments['foo'];
+    element.changeComments = new ChangeComments(comments);
+    assert.equal(
+      element._computePatchSetCommentsString(
+        element.changeComments,
+        1 as PatchSetNum
+      ),
+      ' (2 comments)'
+    );
+
+    // Test string with no comments.
+    delete comments['bar'];
+    element.changeComments = new ChangeComments(comments);
+    assert.equal(
+      element._computePatchSetCommentsString(
+        element.changeComments,
+        1 as PatchSetNum
+      ),
+      ''
+    );
+  });
+
+  test('patch-range-change fires', () => {
+    const handler = sinon.stub();
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as PatchSetNum;
+    element.addEventListener('patch-range-change', handler);
+
+    queryAndAssert<GrDropdownList>(
+      element,
+      '#basePatchDropdown'
+    )._handleValueChange('2', [{text: '', value: '2'}]);
+    assert.isTrue(handler.calledOnce);
+    assert.deepEqual(handler.lastCall.args[0].detail, {
+      basePatchNum: 2,
+      patchNum: 3,
+    });
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    queryAndAssert<GrDropdownList>(
+      element,
+      '#patchNumDropdown'
+    )._handleValueChange('edit', [{text: '', value: 'edit'}]);
+    assert.deepEqual(handler.lastCall.args[0].detail, {
+      basePatchNum: 1,
+      patchNum: 'edit',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
index dcf7236..8ce8ce2 100644
--- a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
@@ -55,7 +55,7 @@
   override render() {
     const icon = this.icon ?? '';
     return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon}></iron-icon>
+      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
       <slot></slot>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index ad671da..dddd6a4 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -54,6 +54,7 @@
   ['text/x-erlang', 'erlang'],
   ['text/x-fortran', 'fortran'],
   ['text/x-fsharp', 'fsharp'],
+  ['text/x-gherkin', 'gherkin'],
   ['text/x-go', 'go'],
   ['text/x-groovy', 'groovy'],
   ['text/x-haml', 'haml'],
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 24ebd67..a295588 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
@@ -259,7 +259,14 @@
 
   _viewEditInChangeView() {
     if (this._change)
-      GerritNav.navigateToChange(this._change, undefined, undefined, true);
+      GerritNav.navigateToChange(
+        this._change,
+        undefined,
+        undefined,
+        true,
+        undefined,
+        true
+      );
   }
 
   _getFileData(
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 6c8bdb9..39070bd 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -124,8 +124,9 @@
   edit?: boolean;
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
-  queryMap?: Map<string, string> | URLSearchParams;
   commentId?: UrlEncodedCommentId;
+  forceReload?: boolean;
+  tab?: string;
 }
 
 export interface AppElementJustRegisteredParams {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 256e956..25e9de8 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -20,11 +20,8 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-page-nav-styles';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
-import {
-  applyTheme as applyDarkTheme,
-  removeTheme as removeDarkTheme,
-} from '../../../styles/themes/dark-theme';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
@@ -74,6 +71,7 @@
   TimeFormat,
 } from '../../../constants/constants';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -243,7 +241,6 @@
       this.$.groupList.loadData(),
       this.$.identities.loadData(),
       this.$.editPrefs.loadData(),
-      this.$.diffPrefs.loadData(),
     ];
 
     // TODO(dhruvsri): move this to the service
@@ -537,13 +534,14 @@
   _handleToggleDark() {
     if (this._isDark) {
       window.localStorage.removeItem('dark-theme');
-      removeDarkTheme();
     } else {
       window.localStorage.setItem('dark-theme', 'true');
-      applyDarkTheme();
     }
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
+    this.reloadPage();
+  }
+
+  reloadPage() {
+    windowLocationReload();
   }
 
   _showHttpAuth(config?: ServerInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index 78c4a62..c1ebcac 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -20,6 +20,9 @@
   <style include="gr-font-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       color: var(--primary-text-color);
@@ -100,6 +103,7 @@
     </gr-page-nav>
     <div class="main gr-form-styles">
       <h1 class="heading-1">User Settings</h1>
+      <h2 id="Theme">Theme</h2>
       <section class="darkToggle">
         <div class="toggle">
           <paper-toggle-button
@@ -108,13 +112,10 @@
             on-change="_handleToggleDark"
             on-click="_onTapDarkToggle"
           ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">Dark theme (alpha)</div>
+          <div id="darkThemeToggleLabel">
+            Dark theme (the toggle reloads the page)
+          </div>
         </div>
-        <p>
-          Gerrit's dark theme is in early alpha, and almost definitely will not
-          play nicely with themes set by specific Gerrit hosts. Filing feedback
-          via the link in the app footer is strongly encouraged!
-        </p>
       </section>
       <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
         Profile
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 1165f1e..61876fe 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
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import {getComputedStyleValue} from '../../../utils/dom-util';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -130,23 +129,24 @@
       await element._testOnly_loadingPromise;
   });
 
-  test('theme changing', () => {
+  test('theme changing', async () => {
+    const reloadStub = sinon.stub(element, 'reloadPage');
+
     window.localStorage.removeItem('dark-theme');
     assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
     const themeToggle = queryAndAssert(
       element,
       '.darkToggle paper-toggle-button'
     );
-    /* const themeToggle = element.shadowRoot
-        .querySelector('.darkToggle paper-toggle-button'); */
     MockInteractions.tap(themeToggle);
     assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
-    assert.equal(
-      getComputedStyleValue('--primary-text-color', document.body),
-      '#e8eaed'
-    );
+    assert.isTrue(reloadStub.calledOnce);
+
+    element._isDark = true;
+    await flush();
     MockInteractions.tap(themeToggle);
     assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    assert.isTrue(reloadStub.calledTwice);
   });
 
   test('calls the title-change event', () => {
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 dabf761..0283ca4 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
@@ -261,7 +261,7 @@
           ${!this.hideStatus && account.status
             ? html`<iron-icon
                 class="status"
-                icon="gr-icons:calendar"
+                icon="gr-icons:unavailable"
               ></iron-icon>`
             : ''}
         </span>
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 a23621e..c6fd01c 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
@@ -15,10 +15,6 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-star_html';
-import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
 import {fireAlert} from '../../../utils/event-util';
 import {
@@ -26,6 +22,9 @@
   ShortcutSection,
 } from '../../../services/shortcuts/shortcuts-config';
 import {appContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,44 +38,78 @@
 }
 
 @customElement('gr-change-star')
-export class GrChangeStar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeStar extends LitElement {
   /**
    * Fired when star state is toggled.
    *
    * @event toggle-star
    */
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   change?: ChangeInfo;
 
   private readonly shortcuts = appContext.shortcutsService;
 
-  _computeStarClass(starred?: boolean) {
-    return starred ? 'active' : '';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        button {
+          background-color: transparent;
+          cursor: pointer;
+        }
+        iron-icon.active {
+          fill: var(--link-color);
+        }
+        iron-icon {
+          vertical-align: top;
+          --iron-icon-height: var(
+            --gr-change-star-size,
+            var(--line-height-normal, 20px)
+          );
+          --iron-icon-width: var(
+            --gr-change-star-size,
+            var(--line-height-normal, 20px)
+          );
+        }
+        :host([hidden]) {
+          visibility: hidden;
+          display: block !important;
+        }
+      `,
+    ];
   }
 
-  _computeStarIcon(starred?: boolean) {
-    // Hollow star is used to indicate inactive state.
-    return `gr-icons:star${starred ? '' : '-border'}`;
-  }
-
-  _computeAriaLabel(starred?: boolean) {
-    return starred ? 'Unstar this change' : 'Star this change';
+  override render() {
+    return html`
+      <button
+        role="checkbox"
+        title=${this.shortcuts.createTitle(
+          Shortcut.TOGGLE_CHANGE_STAR,
+          ShortcutSection.ACTIONS
+        )}
+        aria-label=${this.change?.starred
+          ? 'Unstar this change'
+          : 'Star this change'}
+        @click=${this.toggleStar}
+      >
+        <iron-icon
+          class=${this.change?.starred ? 'active' : ''}
+          .icon=${`gr-icons:star${this.change?.starred ? '' : '-border'}`}
+        ></iron-icon>
+      </button>
+    `;
   }
 
   toggleStar() {
     // Note: change should always be defined when use gr-change-star
     // but since we don't have a good way to enforce usage to always
     // set the change, we still check it here.
-    if (!this.change) {
-      return;
-    }
+    if (!this.change) return;
+
     const newVal = !this.change.starred;
-    this.set('change.starred', newVal);
+    this.change.starred = newVal;
+    this.requestUpdate('change');
     const detail: ChangeStarToggleStarDetail = {
       change: this.change,
       starred: newVal,
@@ -90,8 +123,4 @@
       })
     );
   }
-
-  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
deleted file mode 100644
index d404795..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    button {
-      background-color: transparent;
-      cursor: pointer;
-    }
-    iron-icon.active {
-      fill: var(--link-color);
-    }
-    iron-icon {
-      vertical-align: top;
-      --iron-icon-height: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-      --iron-icon-width: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-    }
-    :host([hidden]) {
-      visibility: hidden;
-      display: block !important;
-    }
-  </style>
-  <button
-    role="checkbox"
-    title="[[createTitle(Shortcut.TOGGLE_CHANGE_STAR,
-      ShortcutSection.ACTIONS)]]"
-    aria-label="[[_computeAriaLabel(change.starred)]]"
-    on-click="toggleStar"
-  >
-    <iron-icon
-      class$="[[_computeStarClass(change.starred)]]"
-      icon$="[[_computeStarIcon(change.starred)]]"
-    ></iron-icon>
-  </button>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
index 8f411ae..2c5d7a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
@@ -27,45 +27,47 @@
 suite('gr-change-star tests', () => {
   let element: GrChangeStar;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.change = {
       ...createChange(),
       starred: true,
     };
+    await element.updateComplete;
   });
 
   test('star visibility states', async () => {
-    element.set('change.starred', true);
-    await flush();
+    element.change!.starred = true;
+    await element.updateComplete;
     let icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
     assert.isTrue(icon.classList.contains('active'));
     assert.equal(icon.icon, 'gr-icons:star');
 
-    element.set('change.starred', false);
-    await flush();
+    element.change!.starred = false;
+    element.requestUpdate('change');
+    await element.updateComplete;
     icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
     assert.isFalse(icon.classList.contains('active'));
     assert.equal(icon.icon, 'gr-icons:star-border');
   });
 
   test('starring', async () => {
-    element.set('change.starred', false);
-    await flush();
+    element.change!.starred = false;
+    await element.updateComplete;
     assert.equal(element.change!.starred, false);
 
-    MockInteractions.tap(queryAndAssert(element, 'button'));
-    await flush();
+    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    await element.updateComplete;
     assert.equal(element.change!.starred, true);
   });
 
   test('unstarring', async () => {
-    element.set('change.starred', true);
-    await flush();
+    element.change!.starred = true;
+    await element.updateComplete;
     assert.equal(element.change!.starred, true);
 
-    MockInteractions.tap(queryAndAssert(element, 'button'));
-    await flush();
+    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    await element.updateComplete;
     assert.equal(element.change!.starred, false);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 6b2e5c4..7ecced0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -41,7 +41,7 @@
   SpecialFilePath,
 } from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {computed, customElement, observe, property} from '@polymer/decorators';
+import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   CommentRange,
@@ -55,7 +55,6 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
 import {
@@ -201,13 +200,14 @@
   @property({type: Array})
   layers: DiffLayer[] = [];
 
+  @property({type: Object, computed: 'computeDiff(comments, path)'})
+  _diff?: DiffInfo;
+
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
   private readonly reporting = appContext.reportingService;
 
-  private readonly flagsService = appContext.flagsService;
-
   private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
@@ -261,15 +261,19 @@
     this._setInitialExpandedState();
   }
 
-  @computed('comments', 'path')
-  get _diff() {
-    if (this.comments === undefined || this.path === undefined) return;
-    if (!this.comments[0]?.context_lines?.length) return;
+  computeDiff(comments?: UIComment[], path?: string) {
+    if (comments === undefined || path === undefined) return undefined;
+    if (!comments[0]?.context_lines?.length) return undefined;
     const diff = computeDiffFromContext(
-      this.comments[0].context_lines,
-      this.path,
-      this.comments[0].source_content_type
+      comments[0].context_lines,
+      path,
+      comments[0].source_content_type
     );
+    // Do we really have to re-compute (and re-render) the diff?
+    if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
+      return this._diff;
+    }
+
     if (!anyLineTooLong(diff)) {
       this.syntaxLayer.init(diff);
       waitForEventOnce(this, 'render').then(() => {
@@ -370,10 +374,7 @@
   }
 
   _initLayers(disableTokenHighlighting: boolean) {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING) &&
-      !disableTokenHighlighting
-    ) {
+    if (!disableTokenHighlighting) {
       this.layers.push(new TokenHighlightLayer(this));
     }
     this.layers.push(this.syntaxLayer);
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 154a045..4f6702d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -38,11 +38,11 @@
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
-  NumericChangeId,
+  BasePatchSetNum,
   ConfigInfo,
+  NumericChangeId,
   PatchSetNum,
   RepoName,
-  BasePatchSetNum,
 } from '../../../types/common';
 import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -60,6 +60,7 @@
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {Interaction} from '../../../constants/reporting';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -489,6 +490,8 @@
       return this._discardDraft();
     }
 
+    const details = this.commentDetailsForReporting();
+    this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
     this._xhrPromise = this._saveDraft(comment)
       .then(response => {
         this.disabled = false;
@@ -508,6 +511,8 @@
           }
           if (!resComment.patch_set) resComment.patch_set = this.patchNum;
           this.comment = resComment;
+          const details = this.commentDetailsForReporting();
+          this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
           this._fireSave();
           return obj;
         });
@@ -520,6 +525,17 @@
     return this._xhrPromise;
   }
 
+  private commentDetailsForReporting() {
+    return {
+      id: this.comment?.id,
+      message_length: this.comment?.message?.length,
+      in_reply_to: this.comment?.in_reply_to,
+      unresolved: this.comment?.unresolved,
+      path_length: this.comment?.path?.length,
+      line: this.comment?.range?.start_line ?? this.comment?.line,
+    };
+  }
+
   _eraseDraftCommentFromStorage() {
     // Prevents a race condition in which removing the draft comment occurs
     // prior to it being saved.
@@ -765,7 +781,7 @@
     const timer = this.reporting.getTimer(timingLabel);
     this.set('comment.__editing', false);
     return this.save().then(() => {
-      timer.end();
+      timer.end({id: this.comment?.id});
     });
   }
 
@@ -849,7 +865,7 @@
         if (!response.ok) {
           this.discarding = false;
         }
-        timer.end();
+        timer.end({id: this.comment?.id});
         this._fireDiscard();
         return response;
       })
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index e560773..6ddaccf 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -24,6 +24,9 @@
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 import {appContext} from '../../../services/app-context';
+import {diffPreferences$} from '../../../services/user/user-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 export interface GrDiffPreferences {
   $: {
@@ -39,7 +42,7 @@
     contextSelect: GrSelect;
     ignoreWhiteSpace: HTMLInputElement;
   };
-  save(): Promise<void>;
+  save(): void;
 }
 
 @customElement('gr-diff-preferences')
@@ -54,12 +57,22 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly userService = appContext.userService;
 
-  loadData() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
+  private readonly disconnected$ = new Subject();
+
+  override connectedCallback() {
+    super.connectedCallback();
+    diffPreferences$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffPreferences => {
+        this.diffPrefs = diffPreferences;
+      });
+  }
+
+  override disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
   }
 
   _handleDiffPrefsChanged() {
@@ -125,12 +138,10 @@
     this._handleDiffPrefsChanged();
   }
 
-  save() {
-    if (!this.diffPrefs)
-      return Promise.reject(new Error('Missing diff preferences'));
-    return this.restApiService.saveDiffPreferences(this.diffPrefs).then(_ => {
-      this.hasUnsavedChanges = false;
-    });
+  async save() {
+    if (!this.diffPrefs) return;
+    await this.userService.updateDiffPreference(this.diffPrefs);
+    this.hasUnsavedChanges = false;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 6c1404e..41ac3e3 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -51,7 +51,6 @@
 
     element = basicFixture.instantiate();
 
-    await element.loadData();
     await flush();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 8322682..cac3d59 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -17,6 +17,7 @@
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-commands_html';
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
index 5a75c13..f9c08ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     paper-tabs {
       height: 3rem;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 6a34fbb..e2ebb14 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -226,7 +226,7 @@
     return html`
       <div class="status">
         <span class="title">
-          <iron-icon icon="gr-icons:calendar"></iron-icon>
+          <iron-icon icon="gr-icons:unavailable"></iron-icon>
           Status:
         </span>
         <span class="value">${this.account.status}</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 1a6239f..2533e00 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -162,6 +162,8 @@
       <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
       <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
+      <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
index 571272d..47295ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -34,7 +34,7 @@
   }
 
   @property({type: String, notify: true})
-  bindValue?: string | number;
+  bindValue?: string | number | boolean;
 
   get nativeSelect() {
     // gr-select is not a shadow component
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 9e6b42a..ce1b282 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -32,6 +32,7 @@
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {addShortcut, Key} from '../../../utils/dom-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -63,10 +64,6 @@
   match: string;
 }
 
-interface ValueChangeEvent {
-  value: string;
-}
-
 export interface GrTextarea {
   $: {
     textarea: IronAutogrowTextareaElement;
@@ -79,7 +76,6 @@
 declare global {
   interface HTMLElementEventMap {
     'item-selected': CustomEvent<ItemSelectedEvent>;
-    'bind-value-changed': CustomEvent<ValueChangeEvent>;
   }
 }
 
@@ -316,7 +312,7 @@
    * _handleKeydown used for key handling in the this.$.textarea AND all child
    * autocomplete options.
    */
-  _onValueChanged(e: CustomEvent<ValueChangeEvent>) {
+  _onValueChanged(e: BindValueChangeEvent) {
     // Relay the event.
     this.dispatchEvent(
       new CustomEvent('bind-value-changed', {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 0585aec8..4315071 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -210,13 +210,9 @@
     const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
     const right = parentRect.width - left - boxRect.width;
     if (left < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': `${left}px`,
-      });
+      tooltip.arrowCenterOffset = `${left}px`;
     } else if (right < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
-      });
+      tooltip.arrowCenterOffset = `${-0.5 * right}px`;
     }
     tooltip.style.left = `${Math.max(0, left)}px`;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
index 8d3bbb0..3b81f46 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
@@ -25,11 +25,15 @@
 
   function makeTooltip(tooltipRect, parentRect) {
     return {
-      getBoundingClientRect() { return tooltipRect; },
-      updateStyles: sinon.stub(),
+      arrowCenterOffset: '0',
+      getBoundingClientRect() {
+        return tooltipRect;
+      },
       style: {left: 0, top: 0},
       parentElement: {
-        getBoundingClientRect() { return parentRect; },
+        getBoundingClientRect() {
+          return parentRect;
+        },
       },
     };
   }
@@ -66,12 +70,12 @@
         {top: 0, left: 0, width: 1000});
 
     element._positionTooltip(tooltip);
-    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.arrowCenterOffset, '0');
     assert.equal(tooltip.style.left, '175px');
     assert.equal(tooltip.style.top, '100px');
   });
 
-  test('left side position', () => {
+  test('left side position', async () => {
     sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
       return {top: 100, left: 10, width: 50};
     });
@@ -80,10 +84,8 @@
         {top: 0, left: 0, width: 1000});
 
     element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    await element.updateComplete;
+    assert.isBelow(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
     assert.equal(tooltip.style.left, '0px');
     assert.equal(tooltip.style.top, '100px');
   });
@@ -97,10 +99,7 @@
         {top: 0, left: 0, width: 1000});
 
     element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
     assert.equal(tooltip.style.left, '915px');
     assert.equal(tooltip.style.top, '100px');
   });
@@ -115,19 +114,16 @@
 
     element.positionBelow = true;
     element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
     assert.equal(tooltip.style.left, '915px');
     assert.equal(tooltip.style.top, '157.2px');
   });
 
   test('hides tooltip when detached', async () => {
-    sinon.stub(element, '_handleHideTooltip');
+    const handleHideTooltipStub = sinon.stub(element, '_handleHideTooltip');
     element.remove();
     await element.updateComplete;
-    assert.isTrue(element._handleHideTooltip.called);
+    assert.isTrue(handleHideTooltipStub.called);
   });
 
   test('sets up listeners when has-tooltip is changed', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index cab05b4..0e41891 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -14,14 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-tooltip_html';
-import {customElement, property, observe} from '@polymer/decorators';
 
-export interface GrTooltip {
-  $: {};
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {styleMap} from 'lit/directives/style-map';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,22 +27,78 @@
 }
 
 @customElement('gr-tooltip')
-export class GrTooltip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrTooltip extends LitElement {
   @property({type: String})
   text = '';
 
   @property({type: String})
   maxWidth = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: String})
+  arrowCenterOffset = '0';
+
+  @property({type: Boolean, reflect: true, attribute: 'position-below'})
   positionBelow = false;
 
-  @observe('maxWidth')
-  _updateWidth(maxWidth: string) {
-    this.updateStyles({'--tooltip-max-width': maxWidth});
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          --gr-tooltip-arrow-size: 0.5em;
+
+          background-color: var(--tooltip-background-color);
+          box-shadow: var(--elevation-level-2);
+          color: var(--tooltip-text-color);
+          font-size: var(--font-size-small);
+          position: absolute;
+          z-index: 1000;
+        }
+        :host .tooltip {
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        :host .arrowPositionBelow,
+        :host([position-below]) .arrowPositionAbove {
+          display: none;
+        }
+        :host([position-below]) .arrowPositionBelow {
+          display: initial;
+        }
+        .arrow {
+          border-left: var(--gr-tooltip-arrow-size) solid transparent;
+          border-right: var(--gr-tooltip-arrow-size) solid transparent;
+          height: 0;
+          position: absolute;
+          left: calc(50% - var(--gr-tooltip-arrow-size));
+          width: 0;
+        }
+        .arrowPositionAbove {
+          border-top: var(--gr-tooltip-arrow-size) solid
+            var(--tooltip-background-color);
+          bottom: calc(-1 * var(--gr-tooltip-arrow-size));
+        }
+        .arrowPositionBelow {
+          border-bottom: var(--gr-tooltip-arrow-size) solid
+            var(--tooltip-background-color);
+          top: calc(-1 * var(--gr-tooltip-arrow-size));
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    this.style.maxWidth = this.maxWidth;
+
+    return html` <div class="tooltip">
+      <i
+        class="arrowPositionBelow arrow"
+        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+      ></i>
+      ${this.text}
+      <i
+        class="arrowPositionAbove arrow"
+        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+      ></i>
+    </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
deleted file mode 100644
index d59a6c3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      --gr-tooltip-arrow-size: 0.5em;
-      --gr-tooltip-arrow-center-offset: 0;
-
-      background-color: var(--tooltip-background-color);
-      box-shadow: var(--elevation-level-2);
-      color: var(--tooltip-text-color);
-      font-size: var(--font-size-small);
-      position: absolute;
-      z-index: 1000;
-      max-width: var(--tooltip-max-width);
-    }
-    :host .tooltip {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    :host .arrowPositionBelow,
-    :host([position-below]) .arrowPositionAbove {
-      display: none;
-    }
-    :host([position-below]) .arrowPositionBelow {
-      display: initial;
-    }
-    .arrow {
-      border-left: var(--gr-tooltip-arrow-size) solid transparent;
-      border-right: var(--gr-tooltip-arrow-size) solid transparent;
-      height: 0;
-      position: absolute;
-      left: calc(50% - var(--gr-tooltip-arrow-size));
-      margin-left: var(--gr-tooltip-arrow-center-offset);
-      width: 0;
-    }
-    .arrowPositionAbove {
-      border-top: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      bottom: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-    .arrowPositionBelow {
-      border-bottom: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      top: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-  </style>
-  <div class="tooltip">
-    <i class="arrowPositionBelow arrow"></i>
-    [[text]]
-    <i class="arrowPositionAbove arrow"></i>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
index 8b44047..b693a9e 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -17,49 +17,45 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-tooltip';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrTooltip} from './gr-tooltip';
+import {queryAndAssert} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromTemplate(html` <gr-tooltip> </gr-tooltip> `);
+const basicFixture = fixtureFromElement('gr-tooltip');
 
 suite('gr-tooltip tests', () => {
   let element: GrTooltip;
-  setup(() => {
+
+  setup(async () => {
     element = basicFixture.instantiate() as GrTooltip;
+    await element.updateComplete;
   });
 
-  test('max-width is respected if set', () => {
+  test('max-width is respected if set', async () => {
     element.text =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
       ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
     element.maxWidth = '50px';
+    await element.updateComplete;
     assert.equal(getComputedStyle(element).width, '50px');
   });
 
-  test('the correct arrow is displayed', () => {
+  test('the correct arrow is displayed', async () => {
     assert.equal(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionBelow')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionBelow')!).display,
       'none'
     );
     assert.notEqual(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionAbove')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionAbove')!).display,
       'none'
     );
     element.positionBelow = true;
+    await element.updateComplete;
     assert.notEqual(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionBelow')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionBelow')!).display,
       'none'
     );
     assert.equal(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionAbove')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionAbove')!).display,
       'none'
     );
   });
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index dd86b38..e6b63e6 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -47,7 +47,7 @@
   let element: HovercardMixinTest;
 
   let button: HTMLElement;
-  let testPromise: MockPromise;
+  let testPromise: MockPromise<void>;
 
   setup(() => {
     testPromise = mockPromise();
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 2839874..863f95f 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,7 +25,6 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
-  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
 }
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 6d31f6c..000887c 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -19,8 +19,9 @@
 import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
   createDefaultPreferences,
-  DiffViewMode,
+  createDefaultDiffPrefs,
 } from '../../constants/constants';
+import {DiffPreferencesInfo, DiffViewMode} from '../../api/diff';
 
 interface UserState {
   /**
@@ -28,10 +29,12 @@
    */
   account?: AccountDetailInfo;
   preferences: PreferencesInfo;
+  diffPreferences: DiffPreferencesInfo;
 }
 
 const initialState: UserState = {
   preferences: createDefaultPreferences(),
+  diffPreferences: createDefaultDiffPrefs(),
 };
 
 // Mutable for testing
@@ -62,6 +65,11 @@
   privateState$.next({...current, preferences});
 }
 
+export function updateDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, diffPreferences});
+}
+
 export const account$ = userState$.pipe(
   map(userState => userState.account),
   distinctUntilChanged()
@@ -72,6 +80,11 @@
   distinctUntilChanged()
 );
 
+export const diffPreferences$ = userState$.pipe(
+  map(userState => userState.diffPreferences),
+  distinctUntilChanged()
+);
+
 export const preferenceDiffViewMode$ = preferences$.pipe(
   map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
   distinctUntilChanged()
@@ -82,6 +95,11 @@
   distinctUntilChanged()
 );
 
+export const sizeBarInChangeTable$ = preferences$.pipe(
+  map(prefs => !!prefs?.size_bar_in_change_table),
+  distinctUntilChanged()
+);
+
 export const disableShortcuts$ = preferences$.pipe(
   map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
index 0588a4a..d08da8b 100644
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ b/polygerrit-ui/app/services/user/user-service.ts
@@ -16,10 +16,19 @@
  */
 import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
 import {from, of} from 'rxjs';
-import {account$, updateAccount, updatePreferences} from './user-model';
+import {
+  account$,
+  updateAccount,
+  updatePreferences,
+  updateDiffPreferences,
+} from './user-model';
 import {switchMap} from 'rxjs/operators';
-import {createDefaultPreferences} from '../../constants/constants';
+import {
+  createDefaultPreferences,
+  createDefaultDiffPrefs,
+} from '../../constants/constants';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../types/diff';
 
 export class UserService {
   constructor(readonly restApiService: RestApiService) {
@@ -38,6 +47,16 @@
       .subscribe((preferences?: PreferencesInfo) => {
         updatePreferences(preferences ?? createDefaultPreferences());
       });
+    account$
+      .pipe(
+        switchMap(account => {
+          if (!account) return of(createDefaultDiffPrefs());
+          return from(this.restApiService.getDiffPreferences());
+        })
+      )
+      .subscribe((diffPrefs?: DiffPreferencesInfo) => {
+        updateDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+      });
   }
 
   updatePreferences(prefs: Partial<PreferencesInfo>) {
@@ -48,4 +67,23 @@
         updatePreferences(newPrefs);
       });
   }
+
+  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+    return this.restApiService
+      .saveDiffPreferences(diffPrefs)
+      .then((response: Response) => {
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as DiffPreferencesInfo;
+          if (!newPrefs) return;
+          updateDiffPreferences(newPrefs);
+        });
+      });
+  }
+
+  getDiffPreferences() {
+    return this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      updateDiffPreferences(prefs);
+    });
+  }
 }
diff --git a/polygerrit-ui/app/styles/gr-paper-styles.ts b/polygerrit-ui/app/styles/gr-paper-styles.ts
new file mode 100644
index 0000000..1ef7124
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-paper-styles.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+import {css} from 'lit';
+
+export const paperStyles = css`
+  paper-toggle-button {
+    --paper-toggle-button-checked-bar-color: var(--link-color);
+    --paper-toggle-button-checked-button-color: var(--link-color);
+  }
+  paper-tabs {
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+    --paper-font-common-base: {
+      font-family: var(--header-font-family);
+      -webkit-font-smoothing: initial;
+    }
+    --paper-tab-content: {
+      margin-bottom: var(--spacing-s);
+    }
+    --paper-tab-content-focused: {
+      /* paper-tabs uses 700 here, which can look awkward */
+      font-weight: var(--font-weight-h3);
+      background: var(--gray-background-focus);
+    }
+    --paper-tab-content-unselected: {
+      /* paper-tabs uses 0.8 here, but we want to control the color directly */
+      opacity: 1;
+      color: var(--deemphasized-text-color);
+    }
+  }
+  paper-tab:focus {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-paper-styles">
+  <template>
+    <style>
+    ${paperStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 98f6eb2..e99cf27 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -189,36 +189,6 @@
   .separator.transparent {
     border-color: transparent;
   }
-  paper-toggle-button {
-    --paper-toggle-button-checked-bar-color: var(--link-color);
-    --paper-toggle-button-checked-button-color: var(--link-color);
-  }
-  paper-tabs {
-    font-size: var(--font-size-h3);
-    font-weight: var(--font-weight-h3);
-    line-height: var(--line-height-h3);
-    --paper-font-common-base: {
-      font-family: var(--header-font-family);
-      -webkit-font-smoothing: initial;
-    }
-    --paper-tab-content: {
-      margin-bottom: var(--spacing-s);
-    }
-    --paper-tab-content-focused: {
-      /* paper-tabs uses 700 here, which can look awkward */
-      font-weight: var(--font-weight-h3);
-      background: var(--gray-background-focus);
-    }
-    --paper-tab-content-unselected: {
-      /* paper-tabs uses 0.8 here, but we want to control the color directly */
-      opacity: 1;
-      color: var(--deemphasized-text-color);
-    }
-  }
-  paper-tab:focus {
-    padding-left: 0px;
-    padding-right: 0px;
-  }
   iron-autogrow-textarea {
     /** This is needed for firefox */
     --iron-autogrow-textarea_-_white-space: pre-wrap;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index a24a666..05c6b7d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -249,10 +249,3 @@
 export function applyTheme() {
   document.head.appendChild(getStyleEl());
 }
-
-export function removeTheme() {
-  const darkThemeEls = document.head.querySelectorAll('#dark-theme');
-  if (darkThemeEls.length) {
-    darkThemeEls.forEach(darkThemeEl => darkThemeEl.remove());
-  }
-}
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.ts b/polygerrit-ui/app/styles/themes/dark-theme_test.ts
deleted file mode 100644
index 16e609e..0000000
--- a/polygerrit-ui/app/styles/themes/dark-theme_test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import '../../test/common-test-setup-karma';
-import {applyTheme, removeTheme} from './dark-theme';
-
-suite('dark-theme test', () => {
-  test('apply and remove theme', () => {
-    applyTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
-    removeTheme();
-    assert.isEmpty(document.head.querySelectorAll('#dark-theme'));
-  });
-});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 949c268..bd5504a 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -30,6 +30,7 @@
   registerTestCleanup,
   addIronOverlayBackdropStyleEl,
   removeIronOverlayBackdropStyleEl,
+  removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
@@ -197,6 +198,7 @@
   cleanupTestUtils();
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
+  removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
   // Reset state
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 351cc13..dd56ce2 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -230,7 +230,10 @@
   };
 }
 
-export function createRevision(patchSetNum = 1): RevisionInfo {
+export function createRevision(
+  patchSetNum = 1,
+  description = ''
+): RevisionInfo {
   return {
     _number: patchSetNum as PatchSetNum,
     commit: createCommit(),
@@ -238,13 +241,14 @@
     kind: RevisionKind.REWORK,
     ref: 'refs/changes/5/6/1' as GitRef,
     uploader: createAccountWithId(),
+    description,
   };
 }
 
-export function createEditRevision(): EditRevisionInfo {
+export function createEditRevision(basePatchNum = 1): EditRevisionInfo {
   return {
     _number: EditPatchSetNum,
-    basePatchNum: 1 as BasePatchSetNum,
+    basePatchNum: basePatchNum as BasePatchSetNum,
     commit: createCommit(),
   };
 }
@@ -270,7 +274,7 @@
   [revisionId: string]: RevisionInfo;
 } {
   const revisions: {[revisionId: string]: RevisionInfo} = {};
-  const revisionDate = TEST_CHANGE_CREATED;
+  let revisionDate = TEST_CHANGE_CREATED;
   const revisionIdStart = 1; // The same as getCurrentRevision
   for (let i = 0; i < count; i++) {
     const revisionId = (i + revisionIdStart).toString(16);
@@ -281,6 +285,7 @@
     };
     revisions[revisionId] = revision;
     // advance 1 day
+    revisionDate = new Date(revisionDate);
     revisionDate.setDate(revisionDate.getDate() + 1);
   }
   return revisions;
@@ -294,12 +299,13 @@
 export function createChangeMessages(count: number): ChangeMessageInfo[] {
   const messageIdStart = 1000;
   const messages: ChangeMessageInfo[] = [];
-  const messageDate = TEST_CHANGE_CREATED;
+  let messageDate = TEST_CHANGE_CREATED;
   for (let i = 0; i < count; i++) {
     messages.push({
       ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
     });
+    messageDate = new Date(messageDate);
     messageDate.setDate(messageDate.getDate() + 1);
   }
   return messages;
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 49ecac8..4a513f8 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -26,20 +26,21 @@
 import {CommentsService} from '../services/comments/comments-service';
 import {UserService} from '../services/user/user-service';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
+import {queryAndAssert, query} from '../utils/common-util';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
-export interface MockPromise extends Promise<unknown> {
-  resolve: (value?: unknown) => void;
+export interface MockPromise<T> extends Promise<T> {
+  resolve: (value?: T) => void;
 }
 
-export const mockPromise = () => {
-  let res: (value?: unknown) => void;
-  const promise: MockPromise = new Promise(resolve => {
+export function mockPromise<T = unknown>(): MockPromise<T> {
+  let res: (value?: T) => void;
+  const promise: MockPromise<T> = new Promise<T | undefined>(resolve => {
     res = resolve;
-  }) as MockPromise;
+  }) as MockPromise<T>;
   promise.resolve = res!;
   return promise;
-};
+}
 
 export function isHidden(el: Element | undefined | null) {
   if (!el) return true;
@@ -160,22 +161,40 @@
   el.parentNode?.removeChild(el);
 }
 
+export function removeThemeStyles() {
+  // Do not remove the light theme, because it is only added once statically,
+  // not once per gr-app instantiation.
+  // document.head.querySelector('#light-theme')?.remove();
+  document.head.querySelector('#dark-theme')?.remove();
+}
+
+export async function waitQueryAndAssert<E extends Element = Element>(
+  el: Element | null | undefined,
+  selector: string
+): Promise<E> {
+  await waitUntil(
+    () => !!query<E>(el, selector),
+    `The element '${selector}' did not appear in the DOM within 1000 ms.`
+  );
+  return queryAndAssert<E>(el, selector);
+}
+
 export function waitUntil(
   predicate: () => boolean,
-  maxMillis = 100
+  message = 'The waitUntil() predicate is still false after 1000 ms.'
 ): Promise<void> {
   const start = Date.now();
-  let sleep = 1;
+  let sleep = 0;
   return new Promise((resolve, reject) => {
     const waiter = () => {
       if (predicate()) {
         return resolve();
       }
-      if (Date.now() - start >= maxMillis) {
-        return reject(new Error('Took to long to waitUntil'));
+      if (Date.now() - start >= 1000) {
+        return reject(new Error(message));
       }
       setTimeout(waiter, sleep);
-      sleep *= 2;
+      sleep = sleep === 0 ? 1 : sleep * 4;
     };
     waiter();
   });
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 223f290..8146eb3 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -98,7 +98,7 @@
 
 export interface DiffPreferencesInfo extends DiffPreferenceInfoApi {
   expand_all_comments?: boolean;
-  cursor_blink_rate: number;
+  cursor_blink_rate?: number;
   manual_review?: boolean;
   retain_header?: boolean;
   skip_deleted?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index b6376c4..f467cf6 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -22,6 +22,7 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  BIND_VALUE_CHANGED = 'bind-value-changed',
   CHANGE = 'change',
   CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
@@ -56,6 +57,8 @@
 declare global {
   interface HTMLElementEventMap {
     /* prettier-ignore */
+    'bind-value-changed': BindValueChangeEvent;
+    /* prettier-ignore */
     'change': ChangeEvent;
     /* prettier-ignore */
     'changed': ChangedEvent;
@@ -102,6 +105,11 @@
   }
 }
 
+export interface BindValueChangeEventDetail {
+  value: string;
+}
+export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
+
 export type ChangeEvent = InputEvent;
 
 export type ChangedEvent = CustomEvent<string>;
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 9dd5be2..e139805 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -28,22 +28,28 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAndAssert} from '../test/test-utils';
+import {mockPromise, queryAndAssert} from '../test/test-utils';
 
-async function keyEventOn(
+/**
+ * You might think that instead of passing in the callback with assertions as a
+ * parameter that you could as well just `await keyEventOn()` and *then* run
+ * your assertions. But at that point the event is not "hot" anymore, so most
+ * likely you want to assert stuff about the event within the callback
+ * parameter.
+ */
+function keyEventOn(
   el: HTMLElement,
   callback: (e: KeyboardEvent) => void,
   keyCode = 75,
   key = 'k'
 ): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
+  const promise = mockPromise<KeyboardEvent>();
   el.addEventListener('keydown', (e: KeyboardEvent) => {
     callback(e);
-    resolve(e);
+    promise.resolve(e);
   });
   MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
+  return promise;
 }
 
 class TestEle extends PolymerElement {
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index b95fa4d..1a48a7b 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {
+  ChangeInfo,
   isQuickLabelInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
@@ -28,6 +29,7 @@
   LabelNameToInfoMap,
   VotingRangeInfo,
 } from '../types/common';
+import {ParsedChangeInfo} from '../types/types';
 import {assertNever, unique} from './common-util';
 
 // Name of the standard Code-Review label.
@@ -243,8 +245,30 @@
   }
 }
 
+/**
+ * Show only applicable.
+ * If there are only legacy requirements, show all legacy requirements.
+ * If there is at least one non-legacy requirement, filter legacy requirements.
+ */
+export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
+  let submit_requirements = (change?.submit_requirements ?? []).filter(
+    req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+  );
+
+  const hasNonLegacyRequirements = submit_requirements.some(
+    req => req.is_legacy === false
+  );
+  if (hasNonLegacyRequirements) {
+    submit_requirements = submit_requirements.filter(
+      req => req.is_legacy === false
+    );
+  }
+
+  return submit_requirements;
+}
+
 // TODO(milutin): This may be temporary for demo purposes
-const PRIORITY_REQUIREMENTS_ORDER: string[] = [
+export const PRIORITY_REQUIREMENTS_ORDER: string[] = [
   StandardLabels.CODE_REVIEW,
   StandardLabels.CODE_OWNERS,
   StandardLabels.PRESUBMIT_VERIFIED,
@@ -263,3 +287,14 @@
   );
   return priorityRequirementList.concat(nonPriorityRequirements);
 }
+
+export function getTriggerVotes(change?: ParsedChangeInfo | ChangeInfo) {
+  const allLabels = Object.keys(change?.labels ?? {});
+  const submitReqs = getRequirements(change);
+  const labelAssociatedWithSubmitReqs = submitReqs
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
+  return allLabels.filter(
+    label => !labelAssociatedWithSubmitReqs.includes(label)
+  );
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 2d59294..142c607 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -24,6 +24,8 @@
   getRepresentativeValue,
   getVotingRange,
   getVotingRangeOrDefault,
+  getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   labelCompare,
   LabelStatus,
@@ -38,9 +40,15 @@
 } from '../types/common';
 import {
   createAccountWithEmail,
+  createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createDetailedLabelInfo,
 } from '../test/test-data-generators';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../api/rest-api';
 
 const VALUES_0 = {
   '0': 'neutral',
@@ -270,4 +278,79 @@
       assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
     });
   });
+
+  suite('getRequirements()', () => {
+    function createChangeInfoWith(
+      submit_requirements: SubmitRequirementResultInfo[]
+    ) {
+      return {
+        ...createChange(),
+        submit_requirements,
+      };
+    }
+    test('only legacy', () => {
+      const requirement = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: true,
+      };
+      const change = createChangeInfoWith([requirement]);
+      assert.deepEqual(getRequirements(change), [requirement]);
+    });
+    test('legacy and non-legacy - filter legacy', () => {
+      const requirement = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: true,
+      };
+      const requirement2 = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: false,
+      };
+      const change = createChangeInfoWith([requirement, requirement2]);
+      assert.deepEqual(getRequirements(change), [requirement2]);
+    });
+    test('filter not applicable', () => {
+      const requirement = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: true,
+      };
+      const requirement2 = {
+        ...createSubmitRequirementResultInfo(),
+        status: SubmitRequirementStatus.NOT_APPLICABLE,
+      };
+      const change = createChangeInfoWith([requirement, requirement2]);
+      assert.deepEqual(getRequirements(change), [requirement]);
+    });
+  });
+
+  suite('getTriggerVotes()', () => {
+    test('no requirements', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), [triggerVote]);
+    });
+    test('no trigger votes, all labels associated with sub requirement', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${triggerVote}=MAX`,
+            },
+          },
+        ],
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), []);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index ce5e5a4..ee4ed8b 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -95,7 +95,7 @@
  * @return The correspondent revision obj from {revisions}
  */
 export function getRevisionByPatchNum(
-  revisions: RevisionInfo[],
+  revisions: (RevisionInfo | EditRevisionInfo)[],
   patchNum: PatchSetNum
 ) {
   for (const rev of revisions) {
@@ -309,10 +309,11 @@
  */
 export function findSortedIndex(
   patchNum: PatchSetNum,
-  revisions: RevisionInfo[]
+  revisions: (RevisionInfo | EditRevisionInfo)[]
 ) {
   revisions = revisions || [];
-  const findNum = (rev: RevisionInfo) => `${rev._number}` === `${patchNum}`;
+  const findNum = (rev: RevisionInfo | EditRevisionInfo) =>
+    `${rev._number}` === `${patchNum}`;
   return revisions.findIndex(findNum);
 }
 
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 43c0765..0b217ec 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -38,3 +38,12 @@
   if (n % 10 === 3 && n % 100 !== 13) return `${n}rd`;
   return `${n}th`;
 }
+
+/**
+ * This converts any inputed value into string.
+ *
+ * This is so typescript checker doesn't fail.
+ */
+export function convertToString(key?: unknown) {
+  return key !== undefined ? String(key) : '';
+}
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 793703e..25561bb 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -14,7 +14,7 @@
     "@polymer/test-fixture": "^4.0.2",
     "accessibility-developer-tools": "^2.12.0",
     "chai": "^4.3.4",
-    "karma": "^6.3.4",
+    "karma": "^6.3.6",
     "karma-chrome-launcher": "^3.1.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 7c7ef45..e9c54e9 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -1094,7 +1094,7 @@
   resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8"
   integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==
 
-"@types/cookie@^0.4.0":
+"@types/cookie@^0.4.1":
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
   integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
@@ -1109,7 +1109,7 @@
     "@types/keygrip" "*"
     "@types/node" "*"
 
-"@types/cors@^2.8.8":
+"@types/cors@^2.8.12":
   version "2.8.12"
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
@@ -1498,10 +1498,10 @@
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
-  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
+base64-arraybuffer@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz#87bd13525626db4a9838e00a508c2b73efcf348c"
+  integrity sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==
 
 base64id@2.0.0, base64id@~2.0.0:
   version "2.0.0"
@@ -1939,7 +1939,7 @@
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.1.1, debug@~4.3.1:
+debug@^4.1.0, debug@^4.1.1, debug@~4.3.1, debug@~4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
   integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
@@ -2085,25 +2085,28 @@
   dependencies:
     once "^1.4.0"
 
-engine.io-parser@~4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e"
-  integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==
+engine.io-parser@~5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.1.tgz#6695fc0f1e6d76ad4a48300ff80db5f6b3654939"
+  integrity sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==
   dependencies:
-    base64-arraybuffer "0.1.4"
+    base64-arraybuffer "~1.0.1"
 
-engine.io@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-4.1.1.tgz#9a8f8a5ac5a5ea316183c489bf7f5b6cf91ace5b"
-  integrity sha512-t2E9wLlssQjGw0nluF6aYyfX8LwYU8Jj0xct+pAhfWfv/YrBn6TSNtEYsgxHIfaMqfrLx07czcMg9bMN6di+3w==
+engine.io@~6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.0.0.tgz#2b993fcd73e6b3a6abb52b40b803651cd5747cf0"
+  integrity sha512-Ui7yl3JajEIaACg8MOUwWvuuwU7jepZqX3BKs1ho7NQRuP4LhN4XIykXhp8bEy+x/DhA0LBZZXYSCkZDqrwMMg==
   dependencies:
+    "@types/cookie" "^0.4.1"
+    "@types/cors" "^2.8.12"
+    "@types/node" ">=10.0.0"
     accepts "~1.3.4"
     base64id "2.0.0"
     cookie "~0.4.1"
     cors "~2.8.5"
     debug "~4.3.1"
-    engine.io-parser "~4.0.0"
-    ws "~7.4.2"
+    engine.io-parser "~5.0.0"
+    ws "~8.2.3"
 
 ent@~2.2.0:
   version "2.2.0"
@@ -2833,10 +2836,10 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.3.4:
-  version "6.3.4"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.4.tgz#359899d3aab3d6b918ea0f57046fd2a6b68565e6"
-  integrity sha512-hbhRogUYIulfkBTZT7xoPrCYhRBnBoqbbL4fszWD0ReFGUxU+LYBr3dwKdAluaDQ/ynT9/7C+Lf7pPNW4gSx4Q==
+karma@^6.3.6:
+  version "6.3.6"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.6.tgz#6f64cdd558c7d0c9da6fcdece156089582694611"
+  integrity sha512-xsiu3D6AjCv6Uq0YKXJgC6TvXX2WloQ5+XtHXmC1lwiLVG617DDV3W2DdM4BxCMKHlmz6l3qESZHFQGHAKvrew==
   dependencies:
     body-parser "^1.19.0"
     braces "^3.0.2"
@@ -2856,10 +2859,10 @@
     qjobs "^1.2.0"
     range-parser "^1.2.1"
     rimraf "^3.0.2"
-    socket.io "^3.1.0"
+    socket.io "^4.2.0"
     source-map "^0.6.1"
     tmp "^0.2.1"
-    ua-parser-js "^0.7.28"
+    ua-parser-js "^0.7.30"
     yargs "^16.1.1"
 
 keygrip@~1.1.0:
@@ -3741,12 +3744,12 @@
     nise "^5.0.1"
     supports-color "^7.1.0"
 
-socket.io-adapter@~2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.1.0.tgz#edc5dc36602f2985918d631c1399215e97a1b527"
-  integrity sha512-+vDov/aTsLjViYTwS9fPy5pEtTkrbEKsw2M+oVSoFGw6OD1IpvlV1VPhUzNbofCQ8oyMbdYJqDtGdmHQK6TdPg==
+socket.io-adapter@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.2.tgz#039cd7c71a52abad984a6d57da2c0b7ecdd3c289"
+  integrity sha512-PBZpxUPYjmoogY0aoaTmo1643JelsaS1CiAwNjRVdrI0X9Seuc19Y2Wife8k88avW6haG8cznvwbubAZwH4Mtg==
 
-socket.io-parser@~4.0.3:
+socket.io-parser@~4.0.4:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0"
   integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==
@@ -3755,20 +3758,17 @@
     component-emitter "~1.3.0"
     debug "~4.3.1"
 
-socket.io@^3.1.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-3.1.2.tgz#06e27caa1c4fc9617547acfbb5da9bc1747da39a"
-  integrity sha512-JubKZnTQ4Z8G4IZWtaAZSiRP3I/inpy8c/Bsx2jrwGrTbKeVU5xd6qkKMHpChYeM3dWZSO0QACiGK+obhBNwYw==
+socket.io@^4.2.0:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.3.1.tgz#c0aa14f3f916a8ab713e83a5bd20c16600245763"
+  integrity sha512-HC5w5Olv2XZ0XJ4gOLGzzHEuOCfj3G0SmoW3jLHYYh34EVsIr3EkW9h6kgfW+K3TFEcmYy8JcPWe//KUkBp5jA==
   dependencies:
-    "@types/cookie" "^0.4.0"
-    "@types/cors" "^2.8.8"
-    "@types/node" ">=10.0.0"
     accepts "~1.3.4"
     base64id "~2.0.0"
-    debug "~4.3.1"
-    engine.io "~4.1.0"
-    socket.io-adapter "~2.1.0"
-    socket.io-parser "~4.0.3"
+    debug "~4.3.2"
+    engine.io "~6.0.0"
+    socket.io-adapter "~2.3.2"
+    socket.io-parser "~4.0.4"
 
 source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
@@ -4056,10 +4056,10 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-ua-parser-js@^0.7.28:
-  version "0.7.28"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
-  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
+ua-parser-js@^0.7.30:
+  version "0.7.30"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.30.tgz#4cf5170e8b55ac553fe8b38df3a82f0669671f0b"
+  integrity sha512-uXEtSresNUlXQ1QL4/3dQORcGv7+J2ookOG2ybA/ga9+HYEXueT2o+8dUJQkpedsyTyCJ6jCCirRcKtdtx1kbg==
 
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
@@ -4218,10 +4218,10 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-ws@~7.4.2:
-  version "7.4.6"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
-  integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
+ws@~8.2.3:
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
+  integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
 
 y18n@^5.0.5:
   version "5.0.8"
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index fd2280c..01ca71c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -67,6 +67,7 @@
 f = text/x-fortran
 factor = text/x-factor
 feathre = text/x-feature
+feature = text/x-gherkin
 fcl = text/x-fcl
 for = text/x-fortran
 formula = text/x-spreadsheet