Merge "Add a "is:pure-revert" predicate"
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/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/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 1990cc0..2581d89 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -618,31 +618,47 @@
     Set<String> distinctApprovals = new HashSet<>();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
         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);
     return allApprovals;
   }
 
-  private static List<String> getMaxMinAnyLabels(
+  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;
+  }
+
+  /** 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) {
     List<String> labels = new ArrayList<>();
     if (labelType.isPresent()) {
@@ -741,10 +757,6 @@
         + (accountId != null ? "," + formatAccount(accountId) : "");
   }
 
-  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) {
     return label.toLowerCase()
         + "="
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/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/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/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-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/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 12cf314..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';
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 ce27a6e..f0a90f4 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';
@@ -1216,27 +1217,46 @@
       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);
+    // _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 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 (!patchRange.patchNum) {
-        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+        this._patchRange = {
+          ...this._patchRange,
+          patchNum: computeLatestPatchNum(this._allPatchSets),
+        };
         rightPatchNumChanged = 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(rightPatchNumChanged).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.
       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(() => {
@@ -1252,8 +1272,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;
     }
@@ -2021,8 +2041,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.
@@ -2030,7 +2050,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;
@@ -2474,14 +2501,28 @@
     ) {
       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() {
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 f125670..155d817 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);
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 4b11771..591aa41 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
@@ -557,13 +557,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);
@@ -571,13 +570,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);
@@ -1315,12 +1312,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);
@@ -1378,7 +1375,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());
@@ -1386,13 +1383,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 () => {
@@ -2063,7 +2062,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();
@@ -2080,7 +2079,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
@@ -2104,7 +2103,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();
     });
@@ -2222,6 +2221,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-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-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index bce4024..7493e2f 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
@@ -691,18 +691,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-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 2f3e1e2..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,11 +29,11 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
-import {unique} from '../../../utils/common-util';
 import {
   extractAssociatedLabels,
   getAllUniqueApprovals,
   getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   hasVotes,
   iconForStatus,
@@ -159,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>
@@ -192,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) {
@@ -267,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/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/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 8e18473..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,13 +14,8 @@
  * 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 {convertToString, pluralize} from '../../../utils/string-util';
 import {appContext} from '../../../services/app-context';
 import {
@@ -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,12 +66,6 @@
   meta_b: GeneratedWebLink[];
 }
 
-export interface GrPatchRangeSelect {
-  $: {
-    patchNumDropdown: GrDropdownList;
-  };
-}
-
 declare global {
   interface HTMLElementEventMap {
     'value-change': DropDownValueChangeEvent;
@@ -92,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;
 
@@ -138,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) {
@@ -154,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();
@@ -182,7 +250,7 @@
       const entry: DropdownItem = this._createDropdownEntry(
         basePatchNum,
         'Patchset ',
-        _sortedRevisions,
+        sortedRevisions,
         changeComments,
         this._getShaForPatch(basePatch)
       );
@@ -191,7 +259,7 @@
         disabled: this._computeLeftDisabled(
           basePatch.num,
           patchNum,
-          _sortedRevisions
+          sortedRevisions
         ),
       });
     }
@@ -217,7 +285,7 @@
   _computeMobileText(
     patchNum: PatchSetNum,
     changeComments: ChangeComments,
-    revisions: RevisionInfo[]
+    revisions: (RevisionInfo | EditRevisionInfo)[]
   ) {
     return (
       `${patchNum}` +
@@ -229,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[] = [];
@@ -248,7 +316,7 @@
       const entry = this._createDropdownEntry(
         patchNum,
         patchNum === 'edit' ? '' : 'Patchset ',
-        _sortedRevisions,
+        sortedRevisions,
         changeComments,
         this._getShaForPatch(patch)
       );
@@ -257,7 +325,7 @@
         disabled: this._computeRightDisabled(
           basePatchNum,
           patchNum,
-          _sortedRevisions
+          sortedRevisions
         ),
       });
     }
@@ -280,7 +348,7 @@
   _createDropdownEntry(
     patchNum: PatchSetNum,
     prefix: string,
-    sortedRevisions: RevisionInfo[],
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[],
     changeComments: ChangeComments,
     sha: string
   ) {
@@ -305,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
@@ -325,7 +384,7 @@
   _computeLeftDisabled(
     basePatchNum: PatchSetNum,
     patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
   ): boolean {
     return (
       findSortedIndex(basePatchNum, sortedRevisions) <=
@@ -350,7 +409,7 @@
   _computeRightDisabled(
     basePatchNum: PatchSetNum,
     patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
   ): boolean {
     if (basePatchNum === ParentPatchSetNum) {
       return false;
@@ -410,7 +469,7 @@
   }
 
   _computePatchSetDescription(
-    revisions: RevisionInfo[],
+    revisions: (RevisionInfo | EditRevisionInfo)[],
     patchNum: PatchSetNum,
     addFrontSpace?: boolean
   ) {
@@ -422,7 +481,7 @@
   }
 
   _computePatchSetDate(
-    revisions: RevisionInfo[],
+    revisions: (RevisionInfo | EditRevisionInfo)[],
     patchNum: PatchSetNum
   ): Timestamp | undefined {
     const rev = getRevisionByPatchNum(revisions, patchNum);
@@ -438,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,
@@ -468,8 +527,4 @@
       new CustomEvent('patch-range-change', {detail, bubbles: false})
     );
   }
-
-  convertToString(value?: unknown) {
-    return convertToString(value);
-  }
 }
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 b268863..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="[[convertToString(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="[[convertToString(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/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 1ab469f..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,6 +20,7 @@
 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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
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 81e4fc4..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);
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..246dcd9 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,
@@ -201,6 +201,9 @@
   @property({type: Array})
   layers: DiffLayer[] = [];
 
+  @property({type: Object, computed: 'computeDiff(comments, path)'})
+  _diff?: DiffInfo;
+
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
@@ -261,15 +264,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(() => {
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-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/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/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/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 ed8d792..4a513f8 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -29,18 +29,18 @@
 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;
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 0fd4ca4..1a48a7b 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -287,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 6af883b..142c607 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -25,6 +25,7 @@
   getVotingRange,
   getVotingRangeOrDefault,
   getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   labelCompare,
   LabelStatus,
@@ -42,6 +43,7 @@
   createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createDetailedLabelInfo,
 } from '../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
@@ -319,4 +321,36 @@
       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/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"