Merge "Update designdoc to current state"
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 9d29980..8197550 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -41,14 +41,8 @@
 [[java]]
 === Java
 
-==== MacOS
-
-On MacOS, ensure that "Java for MacOS X 10.5 Update 4" (or higher) is installed
-and that `JAVA_HOME` is set to the
-link:install.html#Requirements[required Java version].
-
-Java installations can typically be found in
-"/System/Library/Frameworks/JavaVM.framework/Versions".
+Ensure that the link:install.html#Requirements[required Java version]
+is installed and that `JAVA_HOME` is set to it.
 
 To check the installed version of Java, open a terminal window and run:
 
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index fec9c97..a28e230 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -50,6 +50,11 @@
  curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
+[[pretty-json]]
+=== Pretty JSON
+
+By default any JSON in responses is compacted. To get pretty-printed JSON add `pp=1` to the request.
+
 === Authentication
 
 To test APIs that require authentication, the username and password must be specified on
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 94a576c..6e1a9bd 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -10,39 +10,6 @@
 +
 Gerrit is not yet compatible with Java 13 or newer at this time.
 
-[[cryptography]]
-== Configure Java for Strong Cryptography
-
-Support for extra strength cryptographic ciphers: _AES128CTR_, _AES256CTR_,
-_ARCFOUR256_, and _ARCFOUR128_ can be enabled by downloading the _Java
-Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files_
-from Oracle and installing them into your JRE.
-
-[NOTE]
-Installing JCE extensions is optional and export restrictions may apply.
-
-. Download the unlimited strength JCE policy files.
-+
-- link:http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html[JDK7 JCE policy files,role=external,window=_blank]
-- link:http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html[JDK8 JCE policy files,role=external,window=_blank]
-. Uncompress and extract the downloaded file.
-+
-The downloaded file  contains the following files:
-+
-[cols="2"]
-|===
-|README.txt
-|Information about JCE and installation guide
-
-|local_policy.jar
-|Unlimited strength local policy file
-
-|US_export_policy.jar
-|Unlimited strength US export policy file
-|===
-. Install the unlimited strength policy JAR files by following instructions
-found in `README.txt`.
-
 [[download]]
 == Download Gerrit
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 7ef9473..0f3f350 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -371,6 +371,7 @@
 * @polymer/paper-listbox
 * @polymer/paper-tabs
 * @polymer/paper-toggle-button
+* @polymer/paper-tooltip
 
 [[Polymer-2015_license]]
 ----
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 5f0fb65..5db997d 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3328,6 +3328,7 @@
 * @polymer/paper-listbox
 * @polymer/paper-tabs
 * @polymer/paper-toggle-button
+* @polymer/paper-tooltip
 
 [[Polymer-2015_license]]
 ----
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 3a5fb96..f66d430 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6718,6 +6718,10 @@
 A list of link:#context-line[ContextLine] containing the lines of the source
 file where the comment was written. Available only if the "enable-context"
 parameter (see link:#list-change-comments[List Change Comments]) is set.
+|`source_content_type` |optional|
+Mime type of the file where the comment is written. Available only if the
+"enable-context" parameter (see link:#list-change-comments[List Change Comments])
+is set.
 
 |===========================
 
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5e2906f..4697afc 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -48,6 +48,7 @@
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
+* The rules for service accounts are different, see link:#bots[Bots].
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
 right thing, e.g. if the change owner sends a reply, then they are often
@@ -85,7 +86,7 @@
 
 image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
 
-=== Bots
+=== Bots [[bots]]
 
 The attention set is meant for human reviews only. Triggering bots and reacting
 to their results is a different workflow and not in scope of the attenion set.
diff --git a/WORKSPACE b/WORKSPACE
index c24d4f9..cffbc8d 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -564,36 +564,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "7.2"
+OW2_VERS = "9.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "fa637eb67eb7628c915d73762b681ae7ff0b9731",
+    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "b6e6abe057f23630113f4167c34bda7086691258",
+    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "ca2954e8d92a05bacc28ff465b25c70e0f512497",
+    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "3a23cc36edaf8fc5a89cb100182758ccb5991487",
+    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
+    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
 )
 
 AUTO_VALUE_VERSION = "1.7.4"
@@ -913,28 +913,28 @@
 
 maven_jar(
     name = "mockito",
-    artifact = "org.mockito:mockito-core:2.24.0",
-    sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
+    artifact = "org.mockito:mockito-core:3.3.3",
+    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
 )
 
-BYTE_BUDDY_VERSION = "1.9.7"
+BYTE_BUDDY_VERSION = "1.10.7"
 
 maven_jar(
     name = "bytebuddy",
     artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
+    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
 )
 
 maven_jar(
     name = "bytebuddy-agent",
     artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
+    sha1 = "c472fad33f617228601172682aa64f8b78508045",
 )
 
 maven_jar(
     name = "objenesis",
-    artifact = "org.objenesis:objenesis:2.6",
-    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+    artifact = "org.objenesis:objenesis:3.0.1",
+    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
 )
 
 load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index 4c6769c..2341f6c 100755
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -157,7 +157,7 @@
         BASE_URL + "groups/?suggest=ad&p=All-Projects",
         headers=HEADERS,
         auth=ADMIN_BASIC_AUTH).text))
-    admin_group_name = r.keys()[0]
+    admin_group_name = list(r.keys())[0]
     GROUP_ADMIN = r[admin_group_name]
     GROUP_ADMIN["name"] = admin_group_name
 
@@ -305,7 +305,7 @@
     project_names = create_gerrit_projects(group_names)
 
     for idx, u in enumerate(gerrit_users):
-        for _ in xrange(random.randint(1, 5)):
-            create_change(u, project_names[4 * idx / len(gerrit_users)])
+        for _ in range(random.randint(1, 5)):
+            create_change(u, project_names[4 * idx // len(gerrit_users)])
 
 main()
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 4215255..67e26ec 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -47,6 +47,7 @@
 import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevBlob;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -294,6 +295,23 @@
     return this;
   }
 
+  public PushOneCommit addGitSubmodule(String modulePath, ObjectId commitId) {
+    commitBuilder.edit(
+        new PathEdit(modulePath) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(commitId);
+          }
+        });
+    return this;
+  }
+
+  public PushOneCommit rmFile(String filename) {
+    commitBuilder.rm(filename);
+    return this;
+  }
+
   public Result to(String ref) throws Exception {
     for (Map.Entry<String, String> e : files.entrySet()) {
       commitBuilder.add(e.getKey(), e.getValue());
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index f6e5de3..e7354ab 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -138,6 +138,9 @@
         throws IOException, ConfigInvalidException {
       try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
         ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+        if (projectUpdate.removeAllAccessSections()) {
+          projectConfig.getAccessSections().forEach(as -> projectConfig.remove(as));
+        }
         removePermissions(projectConfig, projectUpdate.removedPermissions());
         addCapabilities(projectConfig, projectUpdate.addedCapabilities());
         addPermissions(projectConfig, projectUpdate.addedPermissions());
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index ea20931..9a9a21a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -294,7 +294,8 @@
     return new AutoValue_TestProjectUpdate.Builder()
         .nameKey(nameKey)
         .allProjectsName(allProjectsName)
-        .projectUpdater(projectUpdater);
+        .projectUpdater(projectUpdater)
+        .removeAllAccessSections(false);
   }
 
   /** Builder for {@link TestProjectUpdate}. */
@@ -314,6 +315,16 @@
 
     abstract ImmutableMap.Builder<TestPermissionKey, Boolean> exclusiveGroupPermissionsBuilder();
 
+    abstract Builder removeAllAccessSections(boolean value);
+
+    /**
+     * Removes all access sections. Useful when testing against a specific set of access sections or
+     * permissions.
+     */
+    public Builder removeAllAccessSections() {
+      return removeAllAccessSections(true);
+    }
+
     /** Adds a permission to be included in this update. */
     public Builder add(TestPermission testPermission) {
       addedPermissionsBuilder().add(testPermission);
@@ -418,6 +429,8 @@
 
   abstract ThrowingConsumer<TestProjectUpdate> projectUpdater();
 
+  abstract boolean removeAllAccessSections();
+
   boolean hasCapabilityUpdates() {
     return !addedCapabilities().isEmpty()
         || removedPermissions().stream().anyMatch(k -> k.section().equals(GLOBAL_CAPABILITIES));
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index e3c0ba6..1ba5592 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -34,7 +34,17 @@
   public enum FileMode {
     FILE,
     SYMLINK,
-    GITLINK
+    GITLINK;
+
+    public static FileMode fromJgitFileMode(org.eclipse.jgit.lib.FileMode jgitFileMode) {
+      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
+      if (jgitFileMode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
+        fileMode = FileMode.SYMLINK;
+      } else if (jgitFileMode == org.eclipse.jgit.lib.FileMode.GITLINK) {
+        fileMode = FileMode.GITLINK;
+      }
+      return fileMode;
+    }
   }
 
   public static class PatchScriptFileInfo {
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 0b755b7..2a94bc8 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -37,6 +35,8 @@
  */
 @AutoValue
 public abstract class CachedProjectConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public abstract Project getProject();
 
   public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
@@ -126,34 +126,10 @@
 
   public abstract ImmutableMap<String, String> getPluginConfigs();
 
-  /**
-   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
-   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
-   *
-   * @param fileName the name of the file. Must end in {@code .config}.
-   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
-   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
-   *     surface validation errors in case of a parsing issue.
-   */
-  public Optional<Config> getProjectLevelConfig(String fileName) {
-    checkState(fileName.endsWith(".config"), "file name must end in .config");
-    if (getProjectLevelConfigs().containsKey(fileName)) {
-      Config config = new Config();
-      try {
-        config.fromText(getProjectLevelConfigs().get(fileName));
-      } catch (ConfigInvalidException e) {
-        // This is OK to propagate as IllegalStateException because it's a programmer error.
-        // The config was converted to a String using Config#toText. So #fromText must not
-        // throw a ConfigInvalidException
-        throw new IllegalStateException("invalid config for " + fileName, e);
-      }
-      return Optional.of(config);
-    }
-    return Optional.empty();
-  }
-
   public abstract ImmutableMap<String, String> getProjectLevelConfigs();
 
+  public abstract ImmutableMap<String, ImmutableConfig> getParsedProjectLevelConfigs();
+
   public static Builder builder() {
     return new AutoValue_CachedProjectConfig.Builder();
   }
@@ -235,8 +211,15 @@
 
     abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
 
+    abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
+
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
+      try {
+        parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
+      } catch (ConfigInvalidException e) {
+        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+      }
       return this;
     }
 
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
index c8c8a76..68d779c 100644
--- a/java/com/google/gerrit/entities/CommentContext.java
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -20,15 +20,24 @@
 /** An entity class representing all context lines of a comment. */
 @AutoValue
 public abstract class CommentContext {
-  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of());
+  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of(), "");
 
-  public static CommentContext create(ImmutableMap<Integer, String> lines) {
-    return new AutoValue_CommentContext(lines);
+  public static CommentContext create(ImmutableMap<Integer, String> lines, String contentType) {
+    return new AutoValue_CommentContext(lines, contentType);
   }
 
   /** Map of {line number, line text} of the context lines of a comment */
   public abstract ImmutableMap<Integer, String> lines();
 
+  /**
+   * Content type of the source file. Useful for syntax highlighting.
+   *
+   * @return text/x-gerrit-commit-message if the file is a commit message.
+   *     <p>text/x-gerrit-merge-list if the file is a merge list.
+   *     <p>The content/mime type, e.g. text/x-c++src otherwise.
+   */
+  public abstract String contentType();
+
   public static CommentContext empty() {
     return EMPTY;
   }
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
new file mode 100644
index 0000000..a5efc14
--- /dev/null
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -0,0 +1,91 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Immutable parsed representation of a {@link org.eclipse.jgit.lib.Config} that can be cached.
+ * Supports only a limited set of operations.
+ */
+public class ImmutableConfig {
+  public static final ImmutableConfig EMPTY = new ImmutableConfig("", new Config());
+
+  private final String stringCfg;
+  private final Config cfg;
+
+  private ImmutableConfig(String stringCfg, Config cfg) {
+    this.stringCfg = stringCfg;
+    this.cfg = cfg;
+  }
+
+  public static ImmutableConfig parse(String stringCfg) throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(stringCfg);
+    return new ImmutableConfig(stringCfg, cfg);
+  }
+
+  /** Returns a mutable copy of this config. */
+  public Config mutableCopy() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(this.cfg.toText());
+    } catch (ConfigInvalidException e) {
+      // Can't happen as we used JGit to format that config.
+      throw new IllegalStateException(e);
+    }
+    return cfg;
+  }
+
+  /** @see Config#getSections() */
+  public Set<String> getSections() {
+    return cfg.getSections();
+  }
+
+  /** @see Config#getNames(String) */
+  public Set<String> getNames(String section) {
+    return cfg.getNames(section);
+  }
+
+  /** @see Config#getNames(String, String) */
+  public Set<String> getNames(String section, String subsection) {
+    return cfg.getNames(section, subsection);
+  }
+
+  /** @see Config#getStringList(String, String, String) */
+  public String[] getStringList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  /** @see Config#getSubsections(String) */
+  public Set<String> getSubsections(String section) {
+    return cfg.getSubsections(section);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ImmutableConfig)) {
+      return false;
+    }
+    return ((ImmutableConfig) o).stringCfg.equals(stringCfg);
+  }
+
+  @Override
+  public int hashCode() {
+    return stringCfg.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 67c6007..1fd0864 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.entities;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
@@ -107,6 +110,17 @@
     public Status status;
     public Account.Id appliedBy;
 
+    /**
+     * Returns a new instance of {@link Label} that contains a new instance for each mutable field.
+     */
+    public Label deepCopy() {
+      Label copy = new Label();
+      copy.label = label;
+      copy.status = status;
+      copy.appliedBy = appliedBy;
+      return copy;
+    }
+
     @Override
     public String toString() {
       StringBuilder sb = new StringBuilder();
@@ -134,6 +148,23 @@
     }
   }
 
+  /**
+   * Returns a new instance of {@link SubmitRecord} that contains a new instance for each mutable
+   * field.
+   */
+  public SubmitRecord deepCopy() {
+    SubmitRecord copy = new SubmitRecord();
+    copy.status = status;
+    copy.errorMessage = errorMessage;
+    if (labels != null) {
+      copy.labels = labels.stream().map(Label::deepCopy).collect(toImmutableList());
+    }
+    if (requirements != null) {
+      copy.requirements = ImmutableList.copyOf(requirements);
+    }
+    return copy;
+  }
+
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 528efe3..b5f40ce 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -110,4 +112,14 @@
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
   public Collection<SubmitRequirementInfo> requirements;
+
+  public ChangeInfo() {}
+
+  public ChangeInfo(ChangeMessageInfo... messages) {
+    this.messages = ImmutableList.copyOf(messages);
+  }
+
+  public ChangeInfo(Map<String, RevisionInfo> revisions) {
+    this.revisions = ImmutableMap.copyOf(revisions);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
new file mode 100644
index 0000000..647dead
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -0,0 +1,191 @@
+// 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.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Gets the differences between two {@link ChangeInfo}s.
+ *
+ * <p>This must be in package {@code com.google.gerrit.extensions.common} for access to protected
+ * constructors.
+ *
+ * <p>This assumes that every class reachable from {@link ChangeInfo} has a non-private constructor
+ * with zero parameters and overrides the equals method.
+ */
+public final class ChangeInfoDiffer {
+
+  /**
+   * Returns the difference between two instances of {@link ChangeInfo}.
+   *
+   * <p>The {@link ChangeInfoDifference} returned has the following properties:
+   *
+   * <p>Unrepeated fields are present in the difference returned when they differ between {@code
+   * oldChangeInfo} and {@code newChangeInfo}. When there's an unrepeated field that's not a {@link
+   * String}, primitive, or enum, its fields are only returned when they differ.
+   *
+   * <p>Entries in {@link Map} fields are returned when a key is present in {@code newChangeInfo}
+   * and not {@code oldChangeInfo}. If a key is present in both, the diff of the value is returned.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#added()} contain only items found
+   * in {@code newChangeInfo} and not {@code oldChangeInfo}.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#removed()} contain only items found
+   * in {@code oldChangeInfo} and not {@code newChangeInfo}.
+   *
+   * @param oldChangeInfo the previous {@link ChangeInfo} to diff against {@code newChangeInfo}
+   * @param newChangeInfo the {@link ChangeInfo} to diff against {@code oldChangeInfo}
+   * @return the difference between the given {@link ChangeInfo}s
+   */
+  public static ChangeInfoDifference getDifference(
+      ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
+    return ChangeInfoDifference.create(
+        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
+        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+  }
+
+  @SuppressWarnings("unchecked") // reflection is used to construct instances of T
+  private static <T> T getAdded(T oldValue, T newValue) {
+    T toPopulate = (T) construct(newValue.getClass());
+    if (toPopulate == null) {
+      return null;
+    }
+
+    for (Field field : newValue.getClass().getDeclaredFields()) {
+      Object newFieldObj = get(field, newValue);
+      if (oldValue == null || newFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+        continue;
+      }
+
+      Object oldFieldObj = get(field, oldValue);
+      if (newFieldObj.equals(oldFieldObj)) {
+        continue;
+      }
+
+      if (isSimple(field.getType()) || oldFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+      } else if (newFieldObj instanceof Collection) {
+        set(
+            field,
+            toPopulate,
+            getAddedForCollection((Collection<?>) oldFieldObj, (Collection<?>) newFieldObj));
+      } else if (newFieldObj instanceof Map) {
+        set(field, toPopulate, getAddedForMap((Map<?, ?>) oldFieldObj, (Map<?, ?>) newFieldObj));
+      } else {
+        // Recurse to set all fields in the non-primitive object.
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      }
+    }
+    return toPopulate;
+  }
+
+  @VisibleForTesting
+  static boolean isSimple(Class<?> c) {
+    return c.isPrimitive()
+        || c.isEnum()
+        || String.class.isAssignableFrom(c)
+        || Number.class.isAssignableFrom(c)
+        || Boolean.class.isAssignableFrom(c)
+        || Timestamp.class.isAssignableFrom(c);
+  }
+
+  @VisibleForTesting
+  static Object construct(Class<?> c) {
+    // Only use constructors without parameters because we can't determine what values to pass.
+    return stream(c.getDeclaredConstructors())
+        .filter(constructor -> constructor.getParameterCount() == 0)
+        .findAny()
+        .map(ChangeInfoDiffer::construct)
+        .orElseThrow(
+            () ->
+                new IllegalStateException("Class " + c + " must have a zero argument constructor"));
+  }
+
+  private static Object construct(Constructor<?> constructor) {
+    try {
+      return constructor.newInstance();
+    } catch (ReflectiveOperationException e) {
+      throw new IllegalStateException("Failed to construct class " + constructor.getName(), e);
+    }
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldCollection} */
+  private static ImmutableList<?> getAddedForCollection(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+    return notInOldCollection.isEmpty() ? null : notInOldCollection;
+  }
+
+  private static ImmutableList<Object> getAdditions(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
+    oldCollection.forEach(
+        v -> {
+          if (duplicatesMap.containsKey(v)) {
+            duplicatesMap.get(v).remove(v);
+          }
+        });
+    return duplicatesMap.values().stream().flatMap(Collection::stream).collect(toImmutableList());
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldMap} */
+  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
+    for (Map.Entry<?, ?> entry : newMap.entrySet()) {
+      Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
+      if (added != null) {
+        additionsBuilder.put(entry.getKey(), added);
+      }
+    }
+    ImmutableMap<Object, Object> additions = additionsBuilder.build();
+    return additions.isEmpty() ? null : additions;
+  }
+
+  private static Object get(Field field, Object obj) {
+    try {
+      return field.get(obj);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format("Access denied getting field %s in %s", field.getName(), obj.getClass()),
+          e);
+    }
+  }
+
+  private static void set(Field field, Object obj, Object value) {
+    try {
+      field.set(obj, value);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Access denied setting field %s in %s", field.getName(), obj.getClass().getName()),
+          e);
+    }
+  }
+
+  private ChangeInfoDiffer() {}
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
new file mode 100644
index 0000000..269c673
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
@@ -0,0 +1,30 @@
+// 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.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.auto.value.AutoValue;
+
+/** The difference between two {@link ChangeInfo}s returned by {@link ChangeInfoDiffer}. */
+@AutoValue
+public abstract class ChangeInfoDifference {
+
+  public abstract ChangeInfo added();
+
+  public abstract ChangeInfo removed();
+
+  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
+    return new AutoValue_ChangeInfoDifference(added, removed);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 07ad71b..10456ff 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -26,6 +26,12 @@
   public String message;
   public Integer _revisionNumber;
 
+  public ChangeMessageInfo() {}
+
+  public ChangeMessageInfo(String message) {
+    this.message = message;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index fcce2b3..35587a0 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -30,6 +30,9 @@
    */
   public List<ContextLineInfo> contextLines;
 
+  /** Mime type of the underlying source file. Only available if context lines are requested. */
+  public String sourceContentType;
+
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index ea61f31..f710ab7 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -36,6 +36,21 @@
   public PushCertificateInfo pushCertificate;
   public String description;
 
+  public RevisionInfo() {}
+
+  public RevisionInfo(String ref) {
+    this.ref = ref;
+  }
+
+  public RevisionInfo(String ref, int number) {
+    this.ref = ref;
+    _number = number;
+  }
+
+  public RevisionInfo(AccountInfo uploader) {
+    this.uploader = uploader;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof RevisionInfo) {
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 40bf249..d77427a 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -368,11 +369,12 @@
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
     PatchList patchList = null;
+    LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+      LabelType type = labelTypes.byLabel(psa.labelId());
       // Only compute patchList if there is a relevant label, since this is expensive.
       if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
         patchList = getPatchList(project, ps, priorPatchSet);
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index 3d75349..0e34e36 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -68,7 +68,7 @@
       @Override
       protected void configure() {
         persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
-            .version(2)
+            .version(4)
             .diskLimit(1 << 30) // limit the total cache size to 1 GB
             .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
             .weigher(CommentContextWeigher.class)
@@ -149,6 +149,7 @@
     @Override
     public byte[] serialize(CommentContext commentContext) {
       AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+      allBuilder.setContentType(commentContext.contentType());
 
       commentContext
           .lines()
@@ -165,9 +166,10 @@
     @Override
     public CommentContext deserialize(byte[] in) {
       ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
-      Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+      AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in);
+      proto.getContextList().stream()
           .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
-      return CommentContext.create(contextLinesMap.build());
+      return CommentContext.create(contextLinesMap.build(), proto.getContentType());
     }
   }
 
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index c93f4b1..a5aca48 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.auto.value.AutoValue;
@@ -23,21 +24,28 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.SrcContentResolver;
 import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import eu.medsea.mimeutil.MimeType;
+import eu.medsea.mimeutil.MimeUtil2;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
@@ -52,17 +60,25 @@
 public class CommentContextLoader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final FileTypeRegistry registry;
   private final GitRepositoryManager repoManager;
   private final Project.NameKey project;
+  private final ProjectState projectState;
 
   public interface Factory {
     CommentContextLoader create(Project.NameKey project);
   }
 
   @Inject
-  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+  CommentContextLoader(
+      FileTypeRegistry registry,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      @Assisted Project.NameKey project) {
+    this.registry = registry;
     this.repoManager = repoManager;
     this.project = project;
+    projectState = projectCache.get(project).orElseThrow(illegalState(project));
   }
 
   /**
@@ -122,7 +138,8 @@
       ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
       throws IOException {
     Text text = Text.forCommit(reader, commit);
-    return createContext(text, commentRange, contextPadding);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
   }
 
   private CommentContext getContextForMergeList(
@@ -130,7 +147,8 @@
       throws IOException {
     ComparisonType cmp = ComparisonType.againstParent(1);
     Text text = Text.forMergeList(cmp, reader, commit);
-    return createContext(text, commentRange, contextPadding);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_MERGE_LIST);
   }
 
   private CommentContext getContextForFilePath(
@@ -150,12 +168,25 @@
         return CommentContext.empty();
       }
       ObjectId id = tw.getObjectId(0);
-      Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-      return createContext(src, commentRange, contextPadding);
+      byte[] sourceContent = SrcContentResolver.getSourceContent(repo, id, tw.getFileMode(0));
+      Text textSrc = new Text(sourceContent);
+      String contentType = getContentType(tw, filePath, textSrc);
+      return createContext(textSrc, commentRange, contextPadding, contentType);
     }
   }
 
-  private static CommentContext createContext(Text src, Range commentRange, int contextPadding) {
+  private String getContentType(TreeWalk tw, String filePath, Text src) {
+    PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(tw.getFileMode(0));
+    String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
+    if (src.size() > 0 && PatchScript.FileMode.SYMLINK != fileMode) {
+      MimeType registryMimeType = registry.getMimeType(filePath, src.getContent());
+      mimeType = registryMimeType.toString();
+    }
+    return FileContentUtil.resolveContentType(projectState, filePath, fileMode, mimeType);
+  }
+
+  private static CommentContext createContext(
+      Text src, Range commentRange, int contextPadding, String contentType) {
     if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
       // TODO(ghareeb): We should throw an exception in this case. See
       // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
@@ -168,7 +199,7 @@
     for (int i = commentRange.start(); i < commentRange.end(); i++) {
       context.put(i, src.getString(i - 1));
     }
-    return CommentContext.create(context.build());
+    return CommentContext.create(context.build(), contentType);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d507531..6e640f3 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -34,6 +35,8 @@
  * issues. Note that autogenerated change messages are not subject to validation.
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
+  public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
+
   private final int maxCumulativeSize;
   private final ChangeNotes.Factory notesFactory;
 
@@ -41,7 +44,9 @@
   CommentCumulativeSizeValidator(
       @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
     this.notesFactory = notesFactory;
-    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change", "cumulativeCommentSizeLimit", DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   @Override
@@ -55,7 +60,13 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+            + notes.getChangeMessages().stream()
+                // Auto-generated change messages are not counted for the limit. This method is not
+                // called when those change messages are created, but we should also skip them when
+                // counting the size for unrelated messages.
+                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
+                .mapToInt(cm -> cm.getMessage().length())
+                .sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index c67df8b..1fde48c 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -234,7 +234,7 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        try (TraceTimer traceTimer =
+        try (TraceTimer ignored =
             TraceContext.newTimer(
                 "Running CommitValidationListener",
                 Metadata.builder()
@@ -330,7 +330,7 @@
       } else if (idList.size() > 1) {
         throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
-        String v = idList.get(idList.size() - 1).trim();
+        String v = idList.get(0).trim();
         // Reject Change-Ids with wrong format and invalid placeholder ID from
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
@@ -450,10 +450,11 @@
 
     private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
       try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
-          RevWalk revWalk = new RevWalk(repository);
           DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(revWalk.getObjectReader(), repository.getConfig());
-        diffFormatter.setDetectRenames(true);
+        diffFormatter.setRepository(repository);
+        // Do not detect renames; that would require reading file contents, which is slow for large
+        // files.
+        diffFormatter.setDetectRenames(false);
         // For merge commits, i.e. >1 parents, we use parent #0 by convention.
         List<DiffEntry> diffEntries =
             diffFormatter.scan(
@@ -554,7 +555,7 @@
 
   /** Execute commit validation plug-ins */
   public static class PluginCommitValidationListener implements CommitValidationListener {
-    private boolean skipValidation;
+    private final boolean skipValidation;
     private final PluginSetContext<CommitValidationListener> commitValidationListeners;
 
     public PluginCommitValidationListener(
@@ -596,7 +597,8 @@
 
     @Override
     public boolean shouldValidateAllCommits() {
-      return commitValidationListeners.stream().anyMatch(v -> v.shouldValidateAllCommits());
+      return commitValidationListeners.stream()
+          .anyMatch(CommitValidationListener::shouldValidateAllCommits);
     }
   }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 59600e0..346d2a6 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -815,7 +815,7 @@
               });
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       SubmitRuleOptions.builder().build();
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index 260c507..eca2658 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -16,34 +16,40 @@
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
-import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.Optional;
 
-public class ComparisonType {
+/** Relation between the old and new commits used in the diff. */
+@AutoValue
+public abstract class ComparisonType {
 
-  /** 1-based parent */
-  private final Integer parentNum;
+  /**
+   * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
+   * not the auto-merge.
+   */
+  abstract Optional<Integer> parentNum();
 
-  private final boolean autoMerge;
+  abstract boolean autoMerge();
 
   public static ComparisonType againstOtherPatchSet() {
-    return new ComparisonType(null, false);
+    return new AutoValue_ComparisonType(Optional.empty(), false);
   }
 
   public static ComparisonType againstParent(int parentNum) {
-    return new ComparisonType(parentNum, false);
+    return new AutoValue_ComparisonType(Optional.of(parentNum), false);
   }
 
   public static ComparisonType againstAutoMerge() {
-    return new ComparisonType(null, true);
+    return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
-  private ComparisonType(Integer parentNum, boolean autoMerge) {
-    this.parentNum = parentNum;
-    this.autoMerge = autoMerge;
+  private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
+    return new AutoValue_ComparisonType(parent, automerge);
   }
 
   public boolean isAgainstParentOrAutoMerge() {
@@ -51,27 +57,43 @@
   }
 
   public boolean isAgainstParent() {
-    return parentNum != null;
+    return parentNum().isPresent();
   }
 
   public boolean isAgainstAutoMerge() {
-    return autoMerge;
+    return autoMerge();
   }
 
-  public int getParentNum() {
-    requireNonNull(parentNum);
-    return parentNum;
+  public Optional<Integer> getParentNum() {
+    return parentNum();
   }
 
   void writeTo(OutputStream out) throws IOException {
-    writeVarInt32(out, parentNum != null ? parentNum : 0);
-    writeVarInt32(out, autoMerge ? 1 : 0);
+    writeVarInt32(out, isAgainstParent() ? parentNum().get() : 0);
+    writeVarInt32(out, autoMerge() ? 1 : 0);
   }
 
   static ComparisonType readFrom(InputStream in) throws IOException {
     int p = readVarInt32(in);
-    Integer parentNum = p > 0 ? p : null;
+    Optional<Integer> parentNum = p > 0 ? Optional.of(p) : Optional.empty();
     boolean autoMerge = readVarInt32(in) != 0;
-    return new ComparisonType(parentNum, autoMerge);
+    return create(parentNum, autoMerge);
+  }
+
+  public FileDiffOutputProto.ComparisonType toProto() {
+    FileDiffOutputProto.ComparisonType.Builder builder =
+        FileDiffOutputProto.ComparisonType.newBuilder().setAutoMerge(autoMerge());
+    if (parentNum().isPresent()) {
+      builder.setParentNum(parentNum().get());
+    }
+    return builder.build();
+  }
+
+  public static ComparisonType fromProto(FileDiffOutputProto.ComparisonType proto) {
+    Optional<Integer> parentNum = Optional.empty();
+    if (proto.hasField(FileDiffOutputProto.ComparisonType.getDescriptor().findFieldByNumber(1))) {
+      parentNum = Optional.of(proto.getParentNum());
+    }
+    return create(parentNum, proto.getAutoMerge());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 8b90531..93aefff 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -74,8 +74,9 @@
   /**
    * Returns the diff for a single file between a patchset commit against its parent or the
    * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
-   * name of the file. This method will return {@link FileDiffOutput#empty(String)} if the requested
-   * file identified by {@code fileName} has unchanged content or does not exist at both commits.
+   * name of the file. This method will return {@link FileDiffOutput#empty(String, ObjectId,
+   * ObjectId)} if the requested file identified by {@code fileName} has unchanged content or does
+   * not exist at both commits.
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
@@ -98,8 +99,8 @@
   /**
    * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
    * fileName} parameter should contain the old name of the file. This method will return {@link
-   * FileDiffOutput#empty(String)} if the requested file identified by {@code fileName} has
-   * unchanged content or does not exist at both commits.
+   * FileDiffOutput#empty(String, ObjectId, ObjectId)} if the requested file identified by {@code
+   * fileName} has unchanged content or does not exist at both commits.
    *
    * @param project a project name representing a git repository.
    * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 16bd135..efb64bc 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -125,7 +125,9 @@
       FileDiffCacheKey key =
           createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
       Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
-      return result.containsKey(fileName) ? result.get(fileName) : FileDiffOutput.empty(fileName);
+      return result.containsKey(fileName)
+          ? result.get(fileName)
+          : FileDiffOutput.empty(fileName, key.oldCommit(), key.newCommit());
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -143,7 +145,9 @@
     FileDiffCacheKey key =
         createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
     Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
-    return result.containsKey(fileName) ? result.get(fileName) : FileDiffOutput.empty(fileName);
+    return result.containsKey(fileName)
+        ? result.get(fileName)
+        : FileDiffOutput.empty(fileName, oldCommit, newCommit);
   }
 
   private Map<String, FileDiffOutput> getModifiedFiles(
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
index aa6b11f..e42dd8c 100644
--- a/java/com/google/gerrit/server/patch/MagicFile.java
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -93,7 +93,7 @@
           }
         default:
           int uninterestingParent =
-              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum().get() : 1;
 
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index c6f7acf..ccc8565 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -31,6 +32,8 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.patch.DiffContentCalculator.DiffCalculatorResult;
 import com.google.gerrit.server.patch.DiffContentCalculator.TextSource;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
@@ -68,7 +71,8 @@
     intralineDiffCalculator = calculator;
   }
 
-  PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
+  /** Convert into {@link PatchScript} using the old diff cache output. */
+  PatchScript toPatchScriptOld(Repository git, PatchList list, PatchListEntry content)
       throws IOException {
 
     PatchFileChange change =
@@ -87,6 +91,68 @@
     return build(sides.a, sides.b, change);
   }
 
+  /** Convert into {@link PatchScript} using the new diff cache output. */
+  PatchScript toPatchScriptNew(Repository git, FileDiffOutput content) throws IOException {
+    PatchFileChange change =
+        new PatchFileChange(
+            content.edits().stream().map(TaggedEdit::jgitEdit).collect(toImmutableList()),
+            content.edits().stream()
+                .filter(TaggedEdit::dueToRebase)
+                .map(TaggedEdit::jgitEdit)
+                .collect(toImmutableSet()),
+            content.headerLines(),
+            getOldName(content.oldPath(), content.changeType()),
+            getNewName(content.oldPath(), content.newPath(), content.changeType()),
+            content.changeType(),
+            content.patchType().orElse(null));
+    SidesResolver sidesResolver = new SidesResolver(git, content.comparisonType());
+    ResolvedSides sides =
+        resolveSides(
+            git,
+            sidesResolver,
+            oldName(change),
+            newName(change),
+            content.oldCommitId(),
+            content.newCommitId());
+    return build(sides.a, sides.b, change);
+  }
+
+  private String getOldName(Optional<String> oldName, ChangeType changeType) {
+    // TODO(ghareeb): We adapt the new diff cache output so that it's compatible with the old diff
+    // cache behaviour. Later on we can cleanup this logic a bit.
+    switch (changeType) {
+      case DELETED:
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        return null;
+      case COPIED:
+      case RENAMED:
+        return oldName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+
+  private String getNewName(
+      Optional<String> oldName, Optional<String> newName, ChangeType changeType) {
+    // TODO(ghareeb): logic for new path is confusing. We adapt the new diff cache output so that
+    // it's compatible with the existing behaviour of Get Diff. Later on we can cleanup this logic a
+    // bit.
+    switch (changeType) {
+      case DELETED:
+        return oldName.get();
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return newName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+
   private ResolvedSides resolveSides(
       Repository git,
       SidesResolver sidesResolver,
@@ -352,16 +418,8 @@
         byte[] srcContent;
         if (reuse) {
           srcContent = other.srcContent;
-
-        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
-          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
-
-        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
-          String strContent = "Subproject commit " + ObjectId.toString(id);
-          srcContent = strContent.getBytes(UTF_8);
-
         } else {
-          srcContent = Text.NO_BYTES;
+          srcContent = SrcContentResolver.getSourceContent(db, id, mode);
         }
         String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
         DisplayMethod displayMethod = DisplayMethod.DIFF;
@@ -405,12 +463,7 @@
       if (mode == FileMode.MISSING) {
         displayMethod = DisplayMethod.NONE;
       }
-      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
-      if (mode == FileMode.SYMLINK) {
-        fileMode = PatchScript.FileMode.SYMLINK;
-      } else if (mode == FileMode.GITLINK) {
-        fileMode = PatchScript.FileMode.GITLINK;
-      }
+      PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(mode);
       return new PatchSide(
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 826198f..91dc6e3 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -24,17 +24,25 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchScriptBuilder.IntraLineDiffCalculatorResult;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -42,15 +50,23 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang.exception.ExceptionUtils;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -66,7 +82,8 @@
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
         DiffPreferencesInfo diffPrefs,
-        CurrentUser currentUser);
+        CurrentUser currentUser,
+        boolean useNewDiffCache);
 
     PatchScriptFactory create(
         ChangeNotes notes,
@@ -74,13 +91,37 @@
         int parentNum,
         PatchSet.Id patchSetB,
         DiffPreferencesInfo diffPrefs,
-        CurrentUser currentUser);
+        CurrentUser currentUser,
+        boolean useNewDiffCache);
+  }
+
+  /** These metrics are temporary for launching the new redesigned diff cache. */
+  @Singleton
+  static class Metrics {
+    final Counter1<String> diffs;
+    static final String MATCH = "match";
+    static final String MISMATCH = "mismatch";
+    static final String ERROR = "error";
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      diffs =
+          metricMaker.newCounter(
+              "diff/get_diff/dark_launch",
+              new Description(
+                      "Total number of matching, non-matching, or error in diffs in the old and new diff cache implementations.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("type", Metadata.Builder::eventType).build());
+    }
   }
 
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
+  private final Metrics metrics;
+  private final ExecutorService executor;
 
   private final String fileName;
   @Nullable private final PatchSet.Id psa;
@@ -92,11 +133,17 @@
   private final ChangeEditUtil editReader;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final DiffOperations diffOperations;
 
   private final Change.Id changeId;
 
   private ChangeNotes notes;
 
+  private final boolean runNewDiffCacheAsync;
+
+  // TODO(ghareeb): temporary field used for testing. Please remove.
+  private final boolean useNewDiffCache;
+
   @AssistedInject
   PatchScriptFactory(
       GitRepositoryManager grm,
@@ -106,12 +153,17 @@
       ChangeEditUtil editReader,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
+      DiffOperations diffOperations,
+      Metrics metrics,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
       @Assisted("patchSetB") PatchSet.Id patchSetB,
       @Assisted DiffPreferencesInfo diffPrefs,
-      @Assisted CurrentUser currentUser) {
+      @Assisted CurrentUser currentUser,
+      @Assisted boolean useNewDiffCache) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -120,6 +172,9 @@
     this.editReader = editReader;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.diffOperations = diffOperations;
+    this.metrics = metrics;
+    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = patchSetA;
@@ -128,6 +183,10 @@
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
 
+    this.runNewDiffCacheAsync =
+        cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_getDiff", false);
+    this.useNewDiffCache = useNewDiffCache;
+
     changeId = patchSetB.changeId();
   }
 
@@ -140,12 +199,17 @@
       ChangeEditUtil editReader,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
+      DiffOperations diffOperations,
+      Metrics metrics,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
       @Assisted PatchSet.Id patchSetB,
       @Assisted DiffPreferencesInfo diffPrefs,
-      @Assisted CurrentUser currentUser) {
+      @Assisted CurrentUser currentUser,
+      @Assisted boolean useNewDiffCache) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -154,6 +218,9 @@
     this.editReader = editReader;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.diffOperations = diffOperations;
+    this.metrics = metrics;
+    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = null;
@@ -162,6 +229,10 @@
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
 
+    this.runNewDiffCacheAsync =
+        cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_getDiff", false);
+    this.useNewDiffCache = useNewDiffCache;
+
     changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
@@ -200,13 +271,31 @@
           bId = edit.get().getEditCommit();
         }
 
-        final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
-        final PatchScriptBuilder b = newBuilder();
-        final PatchListEntry content = list.get(fileName);
-
-        return b.toPatchScript(git, list, content);
+        if (useNewDiffCache) {
+          FileDiffOutput fileDiffOutput =
+              aId == null
+                  ? diffOperations.getModifiedFileAgainstParent(
+                      notes.getProjectName(),
+                      bId,
+                      parentNum == -1 ? null : parentNum + 1,
+                      fileName,
+                      diffPrefs.ignoreWhitespace)
+                  : diffOperations.getModifiedFile(
+                      notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
+          return newBuilder().toPatchScriptNew(git, fileDiffOutput);
+        }
+        PatchScriptBuilder patchScriptBuilder = newBuilder();
+        PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
+        PatchListEntry content = list.get(fileName);
+        PatchScript patchScript = patchScriptBuilder.toPatchScriptOld(git, list, content);
+        if (runNewDiffCacheAsync) {
+          runNewDiffCacheAsyncAndExportMetrics(git, aId, bId, patchScript);
+        }
+        return patchScript;
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
+      } catch (DiffNotAvailableException e) {
+        throw new StorageException(e);
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("File content unavailable");
         throw new NoSuchChangeException(changeId, e);
@@ -222,6 +311,98 @@
     }
   }
 
+  private void runNewDiffCacheAsyncAndExportMetrics(
+      Repository git, ObjectId aId, ObjectId bId, PatchScript expected) {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try {
+                FileDiffOutput fileDiffOutput =
+                    aId == null
+                        ? diffOperations.getModifiedFileAgainstParent(
+                            notes.getProjectName(),
+                            bId,
+                            parentNum == -1 ? null : parentNum + 1,
+                            fileName,
+                            diffPrefs.ignoreWhitespace)
+                        : diffOperations.getModifiedFile(
+                            notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
+                PatchScript patchScript = newBuilder().toPatchScriptNew(git, fileDiffOutput);
+                if (areEqualPatchscripts(patchScript, expected)) {
+                  metrics.diffs.increment(metrics.MATCH);
+                } else {
+                  metrics.diffs.increment(metrics.MISMATCH);
+                  logger.atWarning().atMostEvery(10, TimeUnit.SECONDS).log(
+                      "Mismatching diff for change %s, old commit ID: %s, new commit ID: %s, file name: %s.",
+                      changeId.toString(), aId, bId, fileName);
+                }
+              } catch (DiffNotAvailableException | IOException e) {
+                metrics.diffs.increment(metrics.ERROR);
+                logger.atSevere().atMostEvery(10, TimeUnit.SECONDS).log(
+                    String.format(
+                            "Error computing new diff for change %s, old commit ID: %s, new commit ID: %s.\n",
+                            changeId.toString(), aId, bId)
+                        + ExceptionUtils.getStackTrace(e));
+              }
+            });
+  }
+
+  /**
+   * The comparison is not exhaustive but is using the most important fields. Comparing all fields
+   * will require some work in {@link PatchScript} to, e.g., convert it to autovalue. This
+   * comparison method shall give a strong signal that both patchscripts are almost identical.
+   */
+  private static boolean areEqualPatchscripts(PatchScript ps1, PatchScript ps2) {
+    boolean equal = true;
+    if (!ps1.getChangeType().equals(ps2.getChangeType())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching change type: old = %s, new = %s.", ps1.getChangeType(), ps2.getChangeType());
+    }
+    if (!ps1.getPatchHeader().equals(ps2.getPatchHeader())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching patch header: old = %s, new = %s.",
+          ps1.getPatchHeader(), ps2.getPatchHeader());
+    }
+    if (!Objects.equals(ps1.getOldName(), ps2.getOldName())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching old name: old = %s, new = %s.", ps1.getOldName(), ps2.getOldName());
+    }
+    if (!Objects.equals(ps1.getNewName(), ps2.getNewName())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching new name: old = %s, new = %s.", ps1.getNewName(), ps2.getNewName());
+    }
+    if (!ps1.getEdits().containsAll(ps2.getEdits())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
+    }
+    if (!ps2.getEdits().containsAll(ps1.getEdits())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
+    }
+    if (!ps1.getEditsDueToRebase().equals(ps2.getEditsDueToRebase())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits due to rebase: old = %s, new = %s.",
+          ps1.getEditsDueToRebase(), ps2.getEditsDueToRebase());
+    }
+    if (!ps1.getA().equals(ps2.getA())) {
+      equal = false;
+      logger.atWarning().log("Mismatching sparse file content in old commit.");
+    }
+    if (!ps1.getB().equals(ps2.getB())) {
+      equal = false;
+      logger.atWarning().log("Mismatching sparse file content in new commit.");
+    }
+    return equal;
+  }
+
   private Optional<ObjectId> getAId() {
     if (psa == null) {
       return Optional.empty();
diff --git a/java/com/google/gerrit/server/patch/SrcContentResolver.java b/java/com/google/gerrit/server/patch/SrcContentResolver.java
new file mode 100644
index 0000000..9cd11d2
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SrcContentResolver.java
@@ -0,0 +1,51 @@
+// 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.
+
+package com.google.gerrit.server.patch;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** Resolver of the source content of a specific file */
+public class SrcContentResolver {
+
+  private SrcContentResolver() {}
+
+  /**
+   * Return the source content of a specific file.
+   *
+   * @param repo Git repository.
+   * @param id Git Object ID of the file blob.
+   * @param fileMode File mode of the underlying file as recognized by Git.
+   * @return byte[] source content of the underlying file if the {@code id} is of type blob, or a
+   *     textual representation of the file if it is a git submodule.
+   * @throws IOException the object ID does not exist in the repository or cannot be accessed.
+   */
+  public static byte[] getSourceContent(Repository repo, ObjectId id, FileMode fileMode)
+      throws IOException {
+    if (fileMode.getObjectType() == Constants.OBJ_BLOB) {
+      return Text.asByteArray(repo.open(id, Constants.OBJ_BLOB));
+    }
+    if (fileMode.getObjectType() == Constants.OBJ_COMMIT) {
+      String strContent = "Subproject commit " + ObjectId.toString(id);
+      return strContent.getBytes(UTF_8);
+    }
+    return Text.NO_BYTES;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 63e0b7a..24c03c7 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -30,7 +30,9 @@
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.prettify.common.SparseFileContent.Accessor;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -41,6 +43,7 @@
 import java.util.List;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * This class is used on submit to compute the diff between the latest approved patch-set, and the
@@ -58,15 +61,22 @@
   private final ProjectCache projectCache;
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final PatchListCache patchListCache;
+  private final int maxCumulativeSize;
 
   @Inject
   SubmitWithStickyApprovalDiff(
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
-      PatchListCache patchListCache) {
+      PatchListCache patchListCache,
+      @GerritServerConfig Config serverConfig) {
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.patchListCache = patchListCache;
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change",
+            "cumulativeCommentSizeLimit",
+            CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   public String apply(ChangeNotes notes, CurrentUser currentUser)
@@ -117,6 +127,16 @@
           getDiffForFile(
               notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
     }
+    if (diff.length() > maxCumulativeSize) {
+      // The diff length is not counted as part of the limit (for technical reasons, since we'd
+      // have to call CommentCumulativeSizeValidator), but it's best not to post an extra large
+      // change message here.
+      return String.format(
+          "\n\n%d is the latest approved patch-set.\nThe change was submitted "
+              + "with many unreviewed changes (the diff is too large to show). Please review the "
+              + "diff.",
+          latestApprovedPatchsetId.get());
+    }
     return diff.toString();
   }
 
@@ -143,7 +163,8 @@
             latestApprovedPatchsetId,
             currentPatchsetId,
             diffPreferencesInfo,
-            currentUser);
+            currentUser,
+            /* useNewDiffCache= */ false);
     PatchScript patchScript = null;
     try {
       patchScript = patchScriptFactory.call();
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 63f311b..1bb407d 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -93,6 +93,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
+            .version(2)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -219,19 +220,16 @@
         RawTextComparator cmp = comparatorFor(key.whitespace());
         ComparisonType comparisonType =
             getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
-        RevCommit aCommit =
-            comparisonType.isAgainstParentOrAutoMerge()
-                ? null
-                : DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
         RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
         return magicPath == MagicPath.COMMIT
-            ? createCommitEntry(reader, aCommit, bCommit, cmp, key.diffAlgorithm())
+            ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
             : createMergeListEntry(
                 reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
       } catch (IOException e) {
         logger.atWarning().log("Failed to compute commit entry for key %s", key);
       }
-      return FileDiffOutput.empty(key.newFilePath());
+      return FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
     }
 
     private static RawTextComparator comparatorFor(Whitespace ws) {
@@ -255,13 +253,24 @@
         ObjectReader reader,
         RevCommit oldCommit,
         RevCommit newCommit,
+        ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
-      Text aText = oldCommit != null ? Text.forCommit(reader, oldCommit) : Text.EMPTY;
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forCommit(reader, oldCommit);
       Text bText = Text.forCommit(reader, newCommit);
       return createMagicFileDiffOutput(
-          rawTextComparator, oldCommit, aText, bText, Patch.COMMIT_MSG, diffAlgorithm);
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.COMMIT_MSG,
+          diffAlgorithm);
     }
 
     private FileDiffOutput createMergeListEntry(
@@ -273,20 +282,31 @@
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          oldCommit != null ? Text.forMergeList(comparisonType, reader, oldCommit) : Text.EMPTY;
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forMergeList(comparisonType, reader, oldCommit);
       Text bText = Text.forMergeList(comparisonType, reader, newCommit);
       return createMagicFileDiffOutput(
-          rawTextComparator, oldCommit, aText, bText, Patch.MERGE_LIST, diffAlgorithm);
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.MERGE_LIST,
+          diffAlgorithm);
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
+        ObjectId oldCommit,
+        ObjectId newCommit,
+        ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
-        RevCommit aCommit,
         Text aText,
         Text bText,
         String fileName,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm) {
-      byte[] rawHdr = getRawHeader(aCommit != null, fileName);
+      byte[] rawHdr = getRawHeader(!comparisonType.isAgainstParentOrAutoMerge(), fileName);
       byte[] aContent = aText.getContent();
       byte[] bContent = bText.getContent();
       long size = bContent.length;
@@ -298,6 +318,9 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
+          .oldCommitId(oldCommit)
+          .newCommitId(newCommit)
+          .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
           .newPath(FileHeaderUtil.getNewPath(fileHeader))
           .changeType(changeType)
@@ -370,8 +393,13 @@
                         mainGitDiff.newPath().get())
                 : 0;
 
+        ObjectId oldCommit = augmentedKey.key().oldCommit();
+        ObjectId newCommit = augmentedKey.key().newCommit();
         FileDiffOutput fileDiff =
             FileDiffOutput.builder()
+                .oldCommitId(oldCommit)
+                .newCommitId(newCommit)
+                .comparisonType(getComparisonType(rw, reader, oldCommit, newCommit))
                 .changeType(mainGitDiff.changeType())
                 .patchType(mainGitDiff.patchType())
                 .oldPath(mainGitDiff.oldPath())
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 3348033..e7f47ef 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -24,16 +24,28 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.ComparisonType;
 import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.io.Serializable;
 import java.util.Optional;
 import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** File diff for a single file path. Produced as output of the {@link FileDiffCache}. */
 @AutoValue
 public abstract class FileDiffOutput implements Serializable {
   private static final long serialVersionUID = 1L;
 
+  /** The 20 bytes SHA-1 object ID of the old git commit used in the diff. */
+  public abstract ObjectId oldCommitId();
+
+  /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
+  public abstract ObjectId newCommitId();
+
+  /** Comparison type of old and new commits: against another patchset, parent or auto-merge. */
+  public abstract ComparisonType comparisonType();
+
   /**
    * The file path at the old commit. Returns an empty Optional if {@link #changeType()} is equal to
    * {@link ChangeType#ADDED}.
@@ -95,8 +107,11 @@
   }
 
   /** Returns an entity representing an unchanged file between two commits. */
-  public static FileDiffOutput empty(String filePath) {
+  public static FileDiffOutput empty(String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
     return builder()
+        .oldCommitId(oldCommitId)
+        .newCommitId(newCommitId)
+        .comparisonType(ComparisonType.againstOtherPatchSet()) // not important
         .oldPath(Optional.empty())
         .newPath(Optional.of(filePath))
         .changeType(ChangeType.MODIFIED)
@@ -124,6 +139,8 @@
     if (newPath().isPresent()) {
       result += stringSize(newPath().get());
     }
+    result += 20 + 20; // old and new commit IDs
+    result += 4; // comparison type
     result += 4; // changeType
     if (patchType().isPresent()) {
       result += 4;
@@ -140,6 +157,12 @@
   @AutoValue.Builder
   public abstract static class Builder {
 
+    public abstract Builder oldCommitId(ObjectId value);
+
+    public abstract Builder newCommitId(ObjectId value);
+
+    public abstract Builder comparisonType(ComparisonType value);
+
     public abstract Builder oldPath(Optional<String> value);
 
     public abstract Builder newPath(Optional<String> value);
@@ -173,8 +196,12 @@
 
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
       FileDiffOutputProto.Builder builder =
           FileDiffOutputProto.newBuilder()
+              .setOldCommit(idConverter.toByteString(fileDiff.oldCommitId().toObjectId()))
+              .setNewCommit(idConverter.toByteString(fileDiff.newCommitId().toObjectId()))
+              .setComparisonType(fileDiff.comparisonType().toProto())
               .setSize(fileDiff.size())
               .setSizeDelta(fileDiff.sizeDelta())
               .addAllHeaderLines(fileDiff.headerLines())
@@ -212,9 +239,13 @@
 
     @Override
     public FileDiffOutput deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
       FileDiffOutputProto proto = Protos.parseUnchecked(FileDiffOutputProto.parser(), in);
       FileDiffOutput.Builder builder = FileDiffOutput.builder();
       builder
+          .oldCommitId(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommitId(idConverter.fromByteString(proto.getNewCommit()))
+          .comparisonType(ComparisonType.fromProto(proto.getComparisonType()))
           .size(proto.getSize())
           .sizeDelta(proto.getSizeDelta())
           .headerLines(proto.getHeaderLinesList().stream().collect(ImmutableList.toImmutableList()))
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
index aef2f63..3720680 100644
--- a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -27,7 +27,11 @@
     return new AutoValue_TaggedEdit(edit, dueToRebase);
   }
 
-  abstract Edit edit();
+  public abstract Edit edit();
 
-  abstract boolean dueToRebase();
+  public org.eclipse.jgit.diff.Edit jgitEdit() {
+    return Edit.toJGitEdit(edit());
+  }
+
+  public abstract boolean dueToRebase();
 }
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4825233..d82a318 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -16,14 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.ImmutableConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -73,19 +74,17 @@
 
   private final String fileName;
   private final ProjectState project;
-  private Config cfg;
+  private final ImmutableConfig immutableConfig;
 
-  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
+  public ProjectLevelConfig(
+      String fileName, ProjectState project, @Nullable ImmutableConfig immutableConfig) {
     this.fileName = fileName;
     this.project = project;
-    this.cfg = cfg;
+    this.immutableConfig = immutableConfig == null ? ImmutableConfig.EMPTY : immutableConfig;
   }
 
   public Config get() {
-    if (cfg == null) {
-      cfg = new Config();
-    }
-    return cfg;
+    return immutableConfig.mutableCopy();
   }
 
   public Config getWithInheritance() {
@@ -105,58 +104,61 @@
    * @return a combined config.
    */
   public Config getWithInheritance(boolean merge) {
-    Config cfgWithInheritance = new Config();
-    try {
-      cfgWithInheritance.fromText(get().toText());
-    } catch (ConfigInvalidException e) {
-      // cannot happen
-    }
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    if (parent != null) {
-      Config parentCfg = parent.getConfig(fileName).getWithInheritance();
-      for (String section : parentCfg.getSections()) {
-        Set<String> allNames = get().getNames(section);
-        for (String name : parentCfg.getNames(section)) {
-          String[] parentValues = parentCfg.getStringList(section, null, name);
-          if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
-          } else if (merge) {
-            cfgWithInheritance.setStringList(
-                section,
-                null,
-                name,
-                Stream.concat(
-                        Arrays.stream(cfg.getStringList(section, null, name)),
-                        Arrays.stream(parentValues))
-                    .sorted()
-                    .distinct()
-                    .collect(toList()));
-          }
-        }
+    Config cfg = new Config();
+    // Traverse from All-Projects down to the current project
+    StreamSupport.stream(project.treeInOrder().spliterator(), false)
+        .forEach(
+            parent -> {
+              ImmutableConfig levelCfg = parent.getConfig(fileName).immutableConfig;
+              for (String section : levelCfg.getSections()) {
+                Set<String> allNames = cfg.getNames(section);
+                for (String name : levelCfg.getNames(section)) {
+                  String[] levelValues = levelCfg.getStringList(section, null, name);
+                  if (allNames.contains(name) && merge) {
+                    cfg.setStringList(
+                        section,
+                        null,
+                        name,
+                        Stream.concat(
+                                Arrays.stream(cfg.getStringList(section, null, name)),
+                                Arrays.stream(levelValues))
+                            .sorted()
+                            .distinct()
+                            .collect(toList()));
+                  } else {
+                    cfg.setStringList(section, null, name, Arrays.asList(levelValues));
+                  }
+                }
 
-        for (String subsection : parentCfg.getSubsections(section)) {
-          allNames = get().getNames(section, subsection);
-          for (String name : parentCfg.getNames(section, subsection)) {
-            String[] parentValues = parentCfg.getStringList(section, subsection, name);
-            if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(
-                  section, subsection, name, Arrays.asList(parentValues));
-            } else if (merge) {
-              cfgWithInheritance.setStringList(
-                  section,
-                  subsection,
-                  name,
-                  Streams.concat(
-                          Arrays.stream(cfg.getStringList(section, subsection, name)),
-                          Arrays.stream(parentValues))
-                      .sorted()
-                      .distinct()
-                      .collect(toList()));
-            }
-          }
-        }
-      }
-    }
-    return cfgWithInheritance;
+                for (String subsection : levelCfg.getSubsections(section)) {
+                  allNames = cfg.getNames(section, subsection);
+
+                  Set<String> allNamesLevelCfg = levelCfg.getNames(section, subsection);
+                  if (allNamesLevelCfg.isEmpty()) {
+                    // Set empty subsection.
+                    cfg.setString(section, subsection, null, null);
+                  } else {
+                    for (String name : allNamesLevelCfg) {
+                      String[] levelValues = levelCfg.getStringList(section, subsection, name);
+                      if (allNames.contains(name) && merge) {
+                        cfg.setStringList(
+                            section,
+                            subsection,
+                            name,
+                            Streams.concat(
+                                    Arrays.stream(cfg.getStringList(section, subsection, name)),
+                                    Arrays.stream(levelValues))
+                                .sorted()
+                                .distinct()
+                                .collect(toList()));
+                      } else {
+                        cfg.setStringList(section, subsection, name, Arrays.asList(levelValues));
+                      }
+                    }
+                  }
+                }
+              }
+            });
+    return cfg;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 8c024ef..249eb35 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
 import static java.util.Comparator.comparing;
@@ -176,8 +177,9 @@
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
-    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
+    checkState(fileName.endsWith(".config"), "file name must end in .config. is: " + fileName);
+    return new ProjectLevelConfig(
+        fileName, this, cachedConfig.getParsedProjectLevelConfigs().get(fileName));
   }
 
   public long getMaxObjectSizeLimit() {
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 0e50bb0..c3bcd25 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.common.collect.Streams;
@@ -36,7 +37,6 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Collectors;
 
 /**
  * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
@@ -121,10 +121,18 @@
         return Collections.singletonList(ruleError("Error looking up change " + cd.getId(), e));
       }
 
-      if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
-        SubmitRecord rec = new SubmitRecord();
-        rec.status = SubmitRecord.Status.CLOSED;
-        return Collections.singletonList(rec);
+      if (change.isClosed() && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
+        return cd.notes().getSubmitRecords().stream()
+            .map(
+                r -> {
+                  SubmitRecord record = r.deepCopy();
+                  if (record.status == SubmitRecord.Status.OK) {
+                    // Submit records that were OK when they got merged are CLOSED now.
+                    record.status = SubmitRecord.Status.CLOSED;
+                  }
+                  return record;
+                })
+            .collect(toImmutableList());
       }
 
       // We evaluate all the plugin-defined evaluators,
@@ -133,7 +141,7 @@
           .map(c -> c.call(s -> s.evaluate(cd)))
           .filter(Optional::isPresent)
           .map(Optional::get)
-          .collect(Collectors.toList());
+          .collect(toImmutableList());
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index ad077c0..3b511e1 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -25,7 +25,7 @@
 @AutoValue
 public abstract class SubmitRuleOptions {
   private static final SubmitRuleOptions defaults =
-      new AutoValue_SubmitRuleOptions.Builder().allowClosed(false).build();
+      new AutoValue_SubmitRuleOptions.Builder().recomputeOnClosedChanges(false).build();
 
   public static SubmitRuleOptions defaults() {
     return defaults;
@@ -35,13 +35,16 @@
     return defaults.toBuilder();
   }
 
-  public abstract boolean allowClosed();
+  /**
+   * True if the submit rules should be recomputed even when the change is already closed (merged).
+   */
+  public abstract boolean recomputeOnClosedChanges();
 
   public abstract Builder toBuilder();
 
   @AutoValue.Builder
   public abstract static class Builder {
-    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+    public abstract SubmitRuleOptions.Builder recomputeOnClosedChanges(boolean allowClosed);
 
     public abstract SubmitRuleOptions build();
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index bf56000..8886cca 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -885,7 +885,12 @@
       submitRecords.put(options, records);
       if (!change().isClosed() && submitRecords.size() == 1) {
         // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
-        submitRecords.put(options.toBuilder().allowClosed(!options.allowClosed()).build(), records);
+        submitRecords.put(
+            options
+                .toBuilder()
+                .recomputeOnClosedChanges(!options.recomputeOnClosedChanges())
+                .build(),
+            records);
       }
     }
     return records;
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 4922b57..1b6dc62 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -262,7 +262,8 @@
     }
 
     if (includeSubmitRecords) {
-      SubmitRuleOptions options = SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions options =
+          SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
       eventFactory.addSubmitRecords(c, submitRuleEvaluatorFactory.create(options).evaluate(d));
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 77b58c6..edc8fcf 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -171,7 +171,10 @@
           allComments.stream().map(this::createCommentContextKey).collect(toList());
       ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
       for (T c : allComments) {
-        c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+        CommentContextKey contextKey = createCommentContextKey(c);
+        CommentContext commentContext = allContext.get(contextKey);
+        c.contextLines = toContextLineInfoList(commentContext);
+        c.sourceContentType = commentContext.contentType();
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index b8902b7..af1236e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -91,6 +91,10 @@
   @Option(name = "--intraline")
   boolean intraline;
 
+  // TODO(ghareeb): This is a temporary parameter for debugging. Please remove.
+  @Option(name = "--use-new-diff-cache")
+  boolean useNewDiffCache;
+
   @Inject
   GetDiff(
       ProjectCache projectCache,
@@ -139,13 +143,15 @@
       }
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, basePatchSet.id(), pId, prefs, currentUser.get());
+              notes, fileName, basePatchSet.id(), pId, prefs, currentUser.get(), useNewDiffCache);
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum - 1, pId, prefs, currentUser.get(), useNewDiffCache);
     } else {
-      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, null, pId, prefs, currentUser.get(), useNewDiffCache);
     }
 
     try {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index f486650..ccea90c 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -120,7 +120,7 @@
 
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.builder().build();
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
-      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
+      SUBMIT_RULE_OPTIONS.toBuilder().recomputeOnClosedChanges(true).build();
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 6e971fc..502b15b 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -14,11 +14,10 @@
 
 package gerrit;
 
-import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
@@ -28,8 +27,15 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.IOException;
 import java.util.Iterator;
+import java.util.List;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Given a regular expression, checks it against the file list in the most recent patchset of a
@@ -76,10 +82,22 @@
     engine.r3 = arg3;
     engine.r4 = arg4;
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    Iterator<PatchListEntry> iter = pl.getPatches().iterator();
+    Repository repository = StoredValues.REPOSITORY.get(engine);
 
-    engine.r5 = new JavaObjectTerm(iter);
+    try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      diffFormatter.setRepository(repository);
+      // Do not detect renames; that would require reading file contents, which is slow for large
+      // files.
+      RevCommit commit = StoredValues.COMMIT.get(engine);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(
+              // In case of a merge commit, i.e. >1 parents, we use parent #0 by convention. So
+              // parent #0 is always the right choice, if it exists.
+              commit.getParentCount() > 0 ? commit.getParent(0) : null, commit);
+      engine.r5 = new JavaObjectTerm(diffEntries.iterator());
+    } catch (IOException e) {
+      throw new JavaException(e);
+    }
 
     return engine.jtry5(commit_delta_check, commit_delta_next);
   }
@@ -95,23 +113,22 @@
 
       Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
       @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
+      Iterator<DiffEntry> iter = (Iterator<DiffEntry>) ((JavaObjectTerm) a5).object();
       while (iter.hasNext()) {
-        PatchListEntry patch = iter.next();
-        String newName = patch.getNewName();
-        String oldName = patch.getOldName();
-        Patch.ChangeType changeType = patch.getChangeType();
+        DiffEntry diffEntry = iter.next();
+        String newName = diffEntry.getNewPath();
+        String oldName = diffEntry.getOldPath();
+        DiffEntry.ChangeType changeType = diffEntry.getChangeType();
 
-        if (Patch.isMagic(newName)) {
-          continue;
-        }
-
-        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
+        if ((!isNull(newName) && regex.matcher(newName).find())
+            || (!isNull(oldName) && regex.matcher(oldName).find())) {
           SymbolTerm changeSym = getTypeSymbol(changeType);
-          SymbolTerm newSym = SymbolTerm.create(newName);
-          SymbolTerm oldSym = Prolog.Nil;
-          if (oldName != null) {
-            oldSym = SymbolTerm.create(oldName);
+          SymbolTerm newSym = isNull(newName) ? Prolog.Nil : SymbolTerm.create(newName);
+          SymbolTerm oldSym = isNull(oldName) ? Prolog.Nil : SymbolTerm.create(oldName);
+          // For compatibility with legacy semantics:
+          if (changeSym.equals(delete)) {
+            newSym = oldSym;
+            oldSym = Prolog.Nil;
           }
 
           if (!a2.unify(changeSym, engine.trail)) {
@@ -130,6 +147,10 @@
     }
   }
 
+  private static boolean isNull(String path) {
+    return path.equals("/dev/null");
+  }
+
   private static final class PRED_commit_delta_next extends Operation {
     @Override
     public Operation exec(Prolog engine) {
@@ -152,20 +173,18 @@
     }
   }
 
-  private static SymbolTerm getTypeSymbol(Patch.ChangeType type) {
+  private static SymbolTerm getTypeSymbol(DiffEntry.ChangeType type) {
     switch (type) {
-      case ADDED:
+      case ADD:
         return add;
-      case MODIFIED:
+      case MODIFY:
         return modify;
-      case DELETED:
+      case DELETE:
         return delete;
-      case RENAMED:
+      case RENAME:
         return rename;
-      case COPIED:
+      case COPY:
         return copy;
-      case REWRITE:
-        break;
     }
     throw new IllegalArgumentException("ChangeType not recognized");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
new file mode 100644
index 0000000..bc9f50a5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -0,0 +1,103 @@
+// 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.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class SubmitRuleIT extends AbstractDaemonTest {
+  @Inject private SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Test
+  public void submitRecordsForClosedChanges_parsedBackByDefault() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
+    // this would show up as blocking submission.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    recordsBeforeSubmission.forEach(
+        sr -> sr.status = SubmitRecord.Status.CLOSED); // Set status to closed
+    assertThat(recordsBeforeSubmission).isEqualTo(recordsAfterSubmission);
+  }
+
+  @Test
+  public void submitRecordsForClosedChanges_recomputedIfRequested() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(
+            SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
+    // this would show up as blocking submission.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    assertThat(recordsBeforeSubmission).isNotEqualTo(recordsAfterSubmission);
+    assertThat(recordsAfterSubmission).hasSize(1);
+    List<SubmitRecord.Label> recordLabels = recordsAfterSubmission.get(0).labels;
+
+    assertThat(recordLabels).hasSize(2);
+    assertCodeReviewApproved(recordLabels);
+    assertMyLabelNeed(recordLabels);
+  }
+
+  private void assertCodeReviewApproved(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label haveCodeReview = new SubmitRecord.Label();
+    haveCodeReview.label = "Code-Review";
+    haveCodeReview.status = SubmitRecord.Label.Status.OK;
+    haveCodeReview.appliedBy = admin.id();
+    assertThat(recordLabels).contains(haveCodeReview);
+  }
+
+  private void assertMyLabelNeed(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label needCustomLabel = new SubmitRecord.Label();
+    needCustomLabel.label = "My-Label";
+    needCustomLabel.status = SubmitRecord.Label.Status.NEED;
+    assertThat(recordLabels).contains(needCustomLabel);
+  }
+
+  private void setupCustomBlockingLabel() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertLabelType(
+              LabelType.builder(
+                      "My-Label",
+                      ImmutableList.of(
+                          LabelValue.create((short) 0, "Not approved"),
+                          LabelValue.create((short) 1, "Approved")))
+                  .setFunction(LabelFunction.MAX_WITH_BLOCK)
+                  .build());
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index eba2634..5ca7310 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -21,8 +21,10 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
@@ -30,6 +32,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
@@ -144,6 +147,56 @@
   }
 
   @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    String content = new String(new char[800]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post a submit diff that is almost the cumulativeCommentSizeLimit
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .doesNotContain("many unreviewed changes");
+
+    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
+    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
+    // 800 is not counted).
+    String message = new String(new char[400]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void postSubmitDiffCannotBeTooBig() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    String content = new String(new char[1100]).replace("\0", "a");
+
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted "
+                + "with many unreviewed changes (the diff is too large to show). Please review the "
+                + "diff.");
+  }
+
+  @Test
   public void diffChangeMessageOnSubmitWithStickyVote_addedFile() throws Exception {
     Change.Id changeId = changeOperations.newChange().project(project).create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 68bb66c..ec59674 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -77,6 +77,7 @@
 
   private boolean intraline;
   private boolean useNewDiffCache;
+  private boolean useNewDiffCacheGetDiff;
 
   private ObjectId commit1;
   private String changeId;
@@ -91,6 +92,8 @@
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
     useNewDiffCache = baseConfig.getBoolean("cache", "diff_cache", "useNewDiffCache", false);
+    useNewDiffCacheGetDiff =
+        baseConfig.getBoolean("cache", "diff_cache", "useNewDiffCache_getDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     commit1 =
@@ -2749,6 +2752,7 @@
   public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
     // TODO(ghareeb): fix this test for the new diff cache implementation
     assume().that(useNewDiffCache).isFalse();
+    assume().that(useNewDiffCacheGetDiff).isFalse();
 
     String target = "file.txt";
     String symlink = "link.lnk";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
new file mode 100644
index 0000000..e55f432
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
@@ -0,0 +1,32 @@
+// 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.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Runs the {@link RevisionDiffIT} tests with the new diff cache, enabled for the single file "Get
+ * Diff" endpoint. This is temporary until the new diff cache is fully deployed. The new diff cache
+ * will become the default in the future.
+ */
+public class RevisionNewDiffCacheForSingleFileIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config newDiffCacheConfig() {
+    Config config = new Config();
+    config.setBoolean("cache", "diff_cache", "runNewDiffCacheAsync_getDiff", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index 5fd55ec..ffdbd8e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -22,16 +22,23 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ProjectLevelConfigIT extends AbstractDaemonTest {
+  private static final String PLUGIN_NAME = "test-plugin";
+
   @Inject private ProjectOperations projectOperations;
+  @Inject private PluginConfigFactory pluginConfigFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -185,4 +192,27 @@
     ProjectState state = projectCache.get(project).get();
     assertThat(state.getConfig(configName).get().toText()).isEmpty();
   }
+
+  @Test
+  public void emptySubSectionsCanBeRead() throws Exception {
+    updatePluginConfig(project, "[section \"subsection\"]");
+    Config cfg = pluginConfigFactory.getProjectPluginConfigWithInheritance(project, PLUGIN_NAME);
+    assertThat(cfg.getSubsections("section")).containsExactly("subsection");
+  }
+
+  private void updatePluginConfig(Project.NameKey project, String pluginConfig) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Configure plugin")
+              .add(PLUGIN_NAME + ".config", pluginConfig));
+    }
+    projectCache.evict(project);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
index 548e3fe..8a0ddd3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -31,11 +31,13 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -53,15 +55,37 @@
 
   private static final String FILE_CONTENT =
       String.join("\n", "Line 1 of file", "", "Line 3 of file", "", "", "Line 6 of file");
+  private static final ObjectId dummyCommit =
+      ObjectId.fromString("93e2901bc0b4719ef6081ee6353b49c9cdd97614");
 
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Before
-  public void setUp() {
+  public void setup() throws Exception {
     requestScopeOperations.setApiUser(user.id());
   }
 
   @Test
+  public void commentContextForGitSubmoduleFiles() throws Exception {
+    String submodulePath = "submodule_path";
+
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo).addGitSubmodule(submodulePath, dummyCommit);
+    PushOneCommit.Result pushResult = push.to("refs/for/master");
+    String changeId = pushResult.getChangeId();
+    CommentInput comment =
+        CommentsUtil.newComment(submodulePath, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, pushResult.getCommit().name(), comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(submodulePath);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("1", "Subproject commit " + dummyCommit.getName()));
+  }
+
+  @Test
   public void commentContextForCommitMessageForLineComment() throws Exception {
     PushOneCommit.Result result =
         createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
@@ -319,6 +343,82 @@
         IntStream.range(200, 301).boxed().collect(ImmutableList.toImmutableList()));
   }
 
+  @Test
+  public void commentContextReturnsCorrectContentTypeForCommitMessage() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(COMMIT_MSG);
+    assertThat(comments.get(0).sourceContentType)
+        .isEqualTo(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Java() throws Exception {
+    String javaContent =
+        "public class Main {\n"
+            + " public static void main(String[]args){\n"
+            + " if(args==null){\n"
+            + " System.err.println(\"Something\");\n"
+            + " }\n"
+            + " }\n"
+            + " }";
+    String fileName = "src.java";
+    String changeId = createChangeWithContent(fileName, javaContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", " System.err.println(\"Something\");"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-java");
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Cpp() throws Exception {
+    String cppContent =
+        "#include <iostream>\n"
+            + "\n"
+            + "int main() {\n"
+            + "    std::cout << \"Hello World!\";\n"
+            + "    return 0;\n"
+            + "}";
+    String fileName = "src.cpp";
+    String changeId = createChangeWithContent(fileName, cppContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", "    std::cout << \"Hello World!\";"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
+  }
+
+  private String createChangeWithContent(String fileName, String fileContent, int line)
+      throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, fileName, fileContent, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(fileName, Side.REVISION, line, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
   private String createChangeWithComment(int startLine, int endLine) throws Exception {
     PushOneCommit.Result result =
         createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 5cbc767..0585f74 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -27,7 +28,9 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -39,7 +42,7 @@
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
   private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
+      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
@@ -89,12 +92,116 @@
     assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
   }
 
+  @Test
+  public void testCommitDelta_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('file1\\.txt')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('no such file')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foo/OWNERS")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foobar")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_regexp() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*')");
+    assertThat(statusForRuleAddFile("foo/bar")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_add_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'foo')");
+    assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_modify_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleModifyFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_delete_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'b.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('a\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('b\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
-    PushOneCommit.Result result1 =
+    PushOneCommit.Result result =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
-    return getStatus(result1);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleAddFile(String... filenames) throws Exception {
+    Map<String, String> fileToContentMap =
+        Arrays.stream(filenames).collect(ImmutableMap.toImmutableMap(f -> f, f -> "file content"));
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "subject", fileToContentMap);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleModifyFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                "subject",
+                ImmutableMap.of(PushOneCommit.FILE_NAME, "I've changed!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
   }
 
   private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
@@ -110,15 +217,30 @@
     return getStatus(result);
   }
 
-  private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
-    ChangeData cd = result1.getChange();
+  private SubmitRecord.Status statusForRuleRenamedFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(user.newIdent(), testRepo, "subject", ImmutableMap.of("b.txt", "Hey, it's me!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status getStatus(PushOneCommit.Result result) throws Exception {
+    ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        records = ruleEvaluator.evaluate(cd);
-      }
+    try (AutoCloseable ignored1 = disableChangeIndex();
+        AutoCloseable ignored2 = disableAccountIndex()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      records = ruleEvaluator.evaluate(cd);
     }
 
     assertThat(records).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 00d01d6..7543ba8 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -595,6 +595,15 @@
   }
 
   @Test
+  public void removeAllAccessSections() {
+    projectOperations.allProjectsForUpdate().removeAllAccessSections().update();
+
+    assertThat(projectOperations.project(allProjects).getConfig())
+        .sectionValues("access")
+        .isEmpty();
+  }
+
+  @Test
   public void updatingCapabilitiesNotAllowedForNonAllProjects() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThrows(
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
index 0e832f4..e2a5787 100644
--- a/javatests/com/google/gerrit/entities/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.junit.Test;
@@ -67,4 +68,19 @@
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
   }
+
+  @Test
+  public void deepCopy() {
+    SubmitRecord record = new SubmitRecord();
+    record.status = SubmitRecord.Status.CLOSED;
+    record.errorMessage = "ouch";
+    record.requirements =
+        ImmutableList.of(SubmitRequirement.builder().setFallbackText("foo").setType("baz").build());
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = "Code-Review";
+    record.labels = ImmutableList.of(label);
+
+    assertThat(record).isNotSameInstanceAs(record.deepCopy());
+    assertThat(record).isEqualTo(record.deepCopy());
+  }
 }
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
new file mode 100644
index 0000000..a41d63b
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -0,0 +1,355 @@
+// 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.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ChangeInfoDifferTest {
+
+  private static final String REVISION = "abc123";
+
+  @Test
+  public void getDiff_givenEmptyChangeInfos_returnsEmptyDifference() {
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(new ChangeInfo(), new ChangeInfo());
+
+    // Spot check a few fields, including collections and maps.
+    assertThat(diff.added().branch).isNull();
+    assertThat(diff.added().project).isNull();
+    assertThat(diff.added().currentRevision).isNull();
+    assertThat(diff.added().actions).isNull();
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.added().reviewers).isNull();
+    assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.removed().branch).isNull();
+    assertThat(diff.removed().project).isNull();
+    assertThat(diff.removed().currentRevision).isNull();
+    assertThat(diff.removed().actions).isNull();
+    assertThat(diff.removed().messages).isNull();
+    assertThat(diff.removed().reviewers).isNull();
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_givenUnchangedTopic_returnsNullTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isNull();
+    assertThat(diff.removed().topic).isNull();
+  }
+
+  @Test
+  public void getDiff_givenChangedTopic_returnsTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("old-topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic("new-topic");
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isEqualTo(newChangeInfo.topic);
+    assertThat(diff.removed().topic).isEqualTo(oldChangeInfo.topic);
+  }
+
+  @Test
+  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(
+            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_givenNewAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_withRemovedAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
+  }
+
+  @Test
+  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNotNull();
+    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
+    assertThat(diff.added().assignee.email).isNull();
+    assertThat(diff.removed().assignee).isNotNull();
+    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
+    assertThat(diff.removed().assignee.email).isNull();
+  }
+
+  @Test
+  public void getDiff_whenHashtagsChanged_returnsHashtags() {
+    String removedHashtag = "removed";
+    String addedHashtag = "added";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(removedHashtag, "existing");
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags("existing", addedHashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(addedHashtag);
+    assertThat(diff.removed().hashtags).isNotNull();
+    assertThat(diff.removed().hashtags).containsExactly(removedHashtag);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateHashtagAdded_returnsHashtag() {
+    String hashtag = "hashtag";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag);
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag, hashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(hashtag);
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageUnchanged_returnsNullMessage() {
+    String message = "message";
+    ChangeInfo oldChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+    ChangeInfo newChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageAdded_returnsAdded() {
+    ChangeMessageInfo addedMessage = new ChangeMessageInfo("added");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage, addedMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(addedMessage);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageRemoved_returnsRemoved() {
+    ChangeMessageInfo removedMessage = new ChangeMessageInfo("removed");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage, removedMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNotNull();
+    assertThat(diff.removed().messages).containsExactly(removedMessage);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateMessagesAdded_returnsDuplicates() {
+    ChangeMessageInfo message = new ChangeMessageInfo("message");
+    ChangeInfo oldChangeInfo = new ChangeInfo(message, message);
+    ChangeInfo newChangeInfo = new ChangeInfo(message, message, message, message);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(message, message);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenNoNewRevisions_returnsNullRevisions() {
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneAddedRevision_returnsRevision() {
+    RevisionInfo addedRevision = new RevisionInfo("ref");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of());
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, addedRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isEqualTo(addedRevision.ref);
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevision_returnsModificationsToRevision() {
+    RevisionInfo oldRevision = new RevisionInfo("ref", 1);
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.ref, 2);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.added().revisions.get(REVISION)._number).isEqualTo(newRevision._number);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.removed().revisions.get(REVISION)._number).isEqualTo(oldRevision._number);
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision =
+        new RevisionInfo(
+            new AccountInfo(oldRevision.uploader.name, oldRevision.uploader.email + "2"));
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.email)
+        .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.email)
+        .isEqualTo(oldRevision.uploader.email);
+  }
+
+  @Test
+  public void getDiff_whenOneUnchangedRevisionUploader_returnsNullRevision() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
+    buildObjectWithFullFields(ChangeInfo.class);
+  }
+
+  private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
+    if (c == null) {
+      return null;
+    }
+    Object toPopulate = ChangeInfoDiffer.construct(c);
+    for (Field field : toPopulate.getClass().getDeclaredFields()) {
+      Class<?> parameterizedType = getParameterizedType(field);
+      if (!ChangeInfoDiffer.isSimple(field.getType())
+          && !field.getType().isArray()
+          && !Map.class.isAssignableFrom(field.getType())
+          && !Collection.class.isAssignableFrom(field.getType())) {
+        field.set(toPopulate, buildObjectWithFullFields(field.getType()));
+      } else if (Collection.class.isAssignableFrom(field.getType())
+          && parameterizedType != null
+          && !ChangeInfoDiffer.isSimple(parameterizedType)) {
+        field.set(toPopulate, ImmutableList.of(buildObjectWithFullFields(parameterizedType)));
+      }
+    }
+    return toPopulate;
+  }
+
+  private static Class<?> getParameterizedType(Field field) {
+    if (!Collection.class.isAssignableFrom(field.getType())) {
+      return null;
+    }
+    Type genericType = field.getGenericType();
+    if (genericType instanceof ParameterizedType) {
+      return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+    }
+    return null;
+  }
+
+  private static ChangeInfo createChangeInfoWithTopic(String topic) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.topic = topic;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.assignee = accountInfo;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.hashtags = ImmutableList.copyOf(hashtags);
+    return changeInfo;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 643c7b7..8fe7662 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -14,7 +14,7 @@
   @Test
   public void roundTripValue() {
     CommentContext commentContext =
-        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"), "text/x-java");
 
     byte[] serialized = INSTANCE.serialize(commentContext);
     CommentContext deserialized = INSTANCE.deserialize(serialized);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index 44ea55a..17fd959 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -19,10 +19,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class FileDiffOutputSerializerTest {
@@ -35,6 +37,9 @@
 
     FileDiffOutput fileDiff =
         FileDiffOutput.builder()
+            .oldCommitId(ObjectId.fromString("dd4d2a1498870ca5fe415b33f65d052d69d9eaf5"))
+            .newCommitId(ObjectId.fromString("0cfaab3f2ba76f71798da0a2651f41be8d45f842"))
+            .comparisonType(ComparisonType.againstOtherPatchSet())
             .oldPath(Optional.of("old_file_path.txt"))
             .newPath(Optional.empty())
             .changeType(ChangeType.DELETED)
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
new file mode 100644
index 0000000..5bf5154
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -0,0 +1,137 @@
+// 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.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for diff related logic of {@link DiffOperations}. */
+public class DiffOperationsTest {
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private DiffOperations diffOperations;
+
+  private static final Project.NameKey testProjectName = Project.nameKey("test-project");
+  private Repository repo;
+
+  private final String fileName1 = "file_1.txt";
+  private final String fileContent1 = "File content 1";
+  private final String fileName2 = "file_2.txt";
+  private final String fileContent2 = "File content 2";
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    repo = repoManager.createRepository(testProjectName);
+  }
+
+  @Test
+  public void diffModifiedFileAgainstParent() throws Exception {
+    ImmutableMap<String, String> oldFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableMap<String, String> newFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    FileDiffOutput diffOutput =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+
+    assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
+    assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
+    assertThat(diffOutput.comparisonType().isAgainstParent()).isTrue();
+    assertThat(diffOutput.edits()).hasSize(1);
+  }
+
+  private ObjectId createCommit(
+      Repository repo, ObjectId parentCommit, ImmutableMap<String, String> fileNameToContent)
+      throws IOException {
+    ObjectId treeId = createTree(repo, fileNameToContent);
+    return createCommitInRepo(repo, treeId, parentCommit);
+  }
+
+  private static ObjectId createCommitInRepo(
+      Repository repo, ObjectId treeId, ObjectId parentCommit) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent committer =
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(committer);
+      cb.setAuthor(committer);
+      cb.setMessage("Test commit");
+      if (parentCommit != null) {
+        cb.setParentIds(parentCommit);
+      }
+      ObjectId commitId = oi.insert(cb);
+      oi.flush();
+      oi.close();
+      return commitId;
+    }
+  }
+
+  private static ObjectId createTree(
+      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader); ) {
+      TreeFormatter formatter = new TreeFormatter();
+      for (Map.Entry<String, String> entry : fileNameToContent.entrySet()) {
+        String fileName = entry.getKey();
+        String fileContent = entry.getValue();
+        ObjectId fileObjId = createBlob(repo, fileContent);
+        formatter.append(fileName, rw.lookupBlob(fileObjId));
+      }
+      ObjectId treeId = oi.insert(formatter);
+      oi.flush();
+      oi.close();
+      return treeId;
+    }
+  }
+
+  private static ObjectId createBlob(Repository repo, String content) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId blobId = oi.insert(Constants.OBJ_BLOB, content.getBytes(UTF_8));
+      oi.flush();
+      oi.close();
+      return blobId;
+    }
+  }
+}
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 740c35a..3cd520b 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 740c35ae36f44748b3c91e60ee7dcb2fb6e99549
+Subproject commit 3cd520b1521ff7c558d0cd95274628a3a20de30a
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index a3da3cf..faf126c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -82,7 +82,7 @@
     ],
     // https://eslint.org/docs/rules/new-cap
     'new-cap': ['error', {
-      capIsNewExceptions: ['Polymer', 'GestureEventListeners'],
+      capIsNewExceptions: ['Polymer'],
       capIsNewExceptionPattern: '^.*Mixin$',
     }],
     // https://eslint.org/docs/rules/no-console
@@ -313,7 +313,10 @@
       },
     },
     {
-      files: ['*_test.ts'],
+      files: [
+        '*_test.ts',
+        'test-utils.ts',
+      ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
       },
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 799d1f7..9e3c8f7 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -47,6 +47,7 @@
   changeNumber: number;
   patchsetNumber: number;
   repo: string;
+  commmitMessage?: string;
 }
 
 export interface ChecksProvider {
@@ -247,7 +248,7 @@
   checkName: string | undefined,
   /** Identical to 'name' property of Action entity. */
   actionName: string
-) => Promise<ActionResult>;
+) => Promise<ActionResult> | undefined;
 
 export interface ActionResult {
   /** An empty errorMessage means success. */
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index bd110c8..6797994 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -176,6 +176,7 @@
 export declare interface RenderPreferences {
   hide_left_side?: boolean;
   disable_context_control_buttons?: boolean;
+  show_file_comment_button?: boolean;
 }
 
 /**
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
new file mode 100644
index 0000000..1456c90
--- /dev/null
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -0,0 +1,32 @@
+/**
+ * @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.
+ */
+
+export enum LifeCycle {
+  PLUGIN_LIFE_CYCLE = 'Plugin life cycle',
+  STARTED_AS_USER = 'Started as user',
+  STARTED_AS_GUEST = 'Started as guest',
+  VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
+  VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  EXTENSION_DETECTED = 'Extension detected',
+  PLUGINS_INSTALLED = 'Plugins installed',
+}
+
+export enum Execution {
+  PLUGIN_API = 'plugin-api',
+  REACHABLE_CODE = 'reachable code',
+  METHOD_USED = 'method used',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index cbb3d95..8c756cd 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-icons/gr-icons';
 import '../gr-permission/gr-permission';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-access-section_html';
 import {
@@ -71,9 +70,7 @@
 }
 
 @customElement('gr-access-section')
-export class GrAccessSection extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccessSection extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 4fa84eb..f3a7fd3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-group-list_html';
@@ -50,7 +49,7 @@
 
 @customElement('gr-admin-group-list')
 export class GrAdminGroupList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -98,8 +97,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateGroupCapability();
     fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index f2b4c89..5647b25 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -31,7 +31,6 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-view_html';
@@ -92,9 +91,7 @@
 }
 
 @customElement('gr-admin-view')
-export class GrAdminView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAdminView extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -178,8 +175,8 @@
   private readonly jsAPI = appContext.jsApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.reload();
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index e813bec..992ac54 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -20,9 +20,8 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 import {GerritView} from '../../../services/router/router-model.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 79a3e95..1e38aa9 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
@@ -36,8 +35,8 @@
 }
 
 @customElement('gr-confirm-delete-item-dialog')
-export class GrConfirmDeleteItemDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrConfirmDeleteItemDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 2124949..102768c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -20,7 +20,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-change-dialog_html';
@@ -50,9 +49,7 @@
   };
 }
 @customElement('gr-create-change-dialog')
-export class GrCreateChangeDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateChangeDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -98,8 +95,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     if (!this.repoName) {
       return Promise.resolve();
     }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index e68f6c9..39dbca8 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-group-dialog_html';
@@ -28,9 +27,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-create-group-dialog')
-export class GrCreateGroupDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateGroupDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 6334670..afcb026 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-pointer-dialog_html';
@@ -35,9 +34,7 @@
 }
 
 @customElement('gr-create-pointer-dialog')
-export class GrCreatePointerDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreatePointerDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index f708485..ec768ff 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -20,14 +20,18 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
-import {ProjectInput, RepoName} from '../../../types/common';
+import {
+  BranchName,
+  GroupId,
+  ProjectInput,
+  RepoName,
+} from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
 
@@ -38,9 +42,7 @@
 }
 
 @customElement('gr-create-repo-dialog')
-export class GrCreateRepoDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateRepoDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -53,8 +55,12 @@
     create_empty_commit: true,
     permissions_only: false,
     name: '' as RepoName,
+    branches: [],
   };
 
+  @property({type: String})
+  _defaultBranch?: BranchName;
+
   @property({type: Boolean})
   _repoCreated = false;
 
@@ -62,7 +68,7 @@
   _repoOwner?: string;
 
   @property({type: String})
-  _repoOwnerId?: string;
+  _repoOwnerId?: GroupId;
 
   @property({type: Object})
   _query: AutocompleteQuery;
@@ -91,16 +97,9 @@
     this.hasNewRepoName = !!name;
   }
 
-  @observe('_repoOwnerId')
-  _repoOwnerIdUpdate(id?: string) {
-    if (id) {
-      this.set('_repoConfig.owners', [id]);
-    } else {
-      this.set('_repoConfig.owners', undefined);
-    }
-  }
-
   handleCreateRepo() {
+    if (this._defaultBranch) this._repoConfig.branches = [this._defaultBranch];
+    if (this._repoOwnerId) this._repoConfig.owners = [this._repoOwnerId];
     return this.restApiService
       .createRepo(this._repoConfig)
       .then(repoRegistered => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
index 070ee86..f529ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -46,6 +46,17 @@
         </iron-input>
       </section>
       <section>
+        <span class="title">Default Branch</span>
+        <iron-input autocomplete="off" bind-value="{{_defaultBranch}}">
+          <input
+            is="iron-input"
+            id="defaultBranchNameInput"
+            autocomplete="off"
+            bind-value="{{_defaultBranch}}"
+          />
+        </iron-input>
+      </section>
+      <section>
         <span class="title">Rights inherit from</span>
         <span class="value">
           <gr-autocomplete
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
index f10141a..e6f9bbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -39,7 +39,6 @@
       create_empty_commit: true,
       parent: 'All-Project',
       permissions_only: false,
-      owners: ['testId'],
     };
 
     const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
@@ -55,10 +54,10 @@
 
     element._repoOwner = 'test';
     element._repoOwnerId = 'testId';
+    element._defaultBranch = 'main';
 
     element.$.repoNameInput.bindValue = configInputObj.name;
     element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.ownerInput.text = configInputObj.owners[0];
     element.$.initialCommit.bindValue =
         configInputObj.create_empty_commit;
     element.$.parentRepo.bindValue =
@@ -69,14 +68,15 @@
     assert.deepEqual(element._repoConfig, configInputObj);
 
     element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(
+          {
+            ...configInputObj,
+            owners: ['testId'],
+            branches: ['main'],
+          }
+      ));
       done();
     });
   });
-
-  test('testing observer of _repoOwner', () => {
-    element._repoOwnerId = 'test-5';
-    assert.deepEqual(element._repoConfig.owners, ['test-5']);
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 201b340..959bfa3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-account-link/gr-account-link';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-audit-log_html';
@@ -40,7 +39,7 @@
 
 @customElement('gr-group-audit-log')
 export class GrGroupAuditLog extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -58,8 +57,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     fireTitleChange(this, 'Audit Log');
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
index 268112e..fdd5d15 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -17,8 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-audit-log.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {addListenerForTest} from '../../../test/test-utils.js';
+import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 54f58c2..f7a2c8b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -24,7 +24,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-members_html';
@@ -63,9 +62,7 @@
   };
 }
 @customElement('gr-group-members')
-export class GrGroupMembers extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroupMembers extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -126,8 +123,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroupDetails();
 
     fireTitleChange(this, 'Members');
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 84daef8..1ef80a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -22,7 +22,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group_html';
@@ -71,9 +70,7 @@
 }
 
 @customElement('gr-group')
-export class GrGroup extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroup extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -134,8 +131,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroup();
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index 4c09e30..0668330 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -17,8 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {addListenerForTest} from '../../../test/test-utils.js';
+import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group');
 
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 85ba052..6528d35 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -23,7 +23,6 @@
 import '../../shared/gr-button/gr-button';
 import '../gr-rule-editor/gr-rule-editor';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-permission_html';
@@ -94,9 +93,7 @@
  * @event added-permission-removed
  */
 @customElement('gr-permission')
-export class GrPermission extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPermission extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 1ab32ea..7c7c948 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -20,7 +20,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-config-array-editor_html';
@@ -38,9 +37,7 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-class GrPluginConfigArrayEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrPluginConfigArrayEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index f5e9a92..c1c2bbd 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -17,7 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-list_html';
@@ -27,8 +26,7 @@
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {PluginInfo} from '../../../types/common';
-import {firePageError} from '../../../utils/event-util';
-import {fireTitleChange} from '../../../utils/event-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 
@@ -37,7 +35,7 @@
 }
 @customElement('gr-plugin-list')
 export class GrPluginList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -80,8 +78,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     fireTitleChange(this, 'Plugins');
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 56c5733..17a9ee6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../gr-access-section/gr-access-section';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-access_html';
@@ -63,9 +62,7 @@
  * @event show-alert
  */
 @customElement('gr-repo-access')
-export class GrRepoAccess extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoAccess extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index f209729..e988a33 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -23,7 +23,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-commands_html';
@@ -61,9 +60,7 @@
 }
 
 @customElement('gr-repo-commands')
-export class GrRepoCommands extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoCommands extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -94,8 +91,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
     fireTitleChange(this, 'Repo Commands');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 7b3c7fb..a132f64 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -17,7 +17,6 @@
 
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-dashboards_html';
@@ -34,9 +33,7 @@
 }
 
 @customElement('gr-repo-dashboards')
-export class GrRepoDashboards extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoDashboards extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index a486e27..4cb325c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -28,7 +28,6 @@
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-detail-list_html';
@@ -63,7 +62,7 @@
 }
 @customElement('gr-repo-detail-list')
 export class GrRepoDetailList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index d6aa0e6..bcbc756 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -20,7 +20,6 @@
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-list_html';
@@ -50,7 +49,7 @@
 
 @customElement('gr-repo-list')
 export class GrRepoList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -91,8 +90,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateRepoCapability();
     fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
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 e9a6158..347a56b 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
@@ -24,7 +24,6 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-plugin-config_html';
@@ -62,9 +61,7 @@
 }
 
 @customElement('gr-repo-plugin-config')
-class GrRepoPluginConfig extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrRepoPluginConfig extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index b6881ff..6ba9ad8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -24,7 +24,6 @@
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo_html';
@@ -83,9 +82,7 @@
 };
 
 @customElement('gr-repo')
-export class GrRepo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepo extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -144,8 +141,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
     fireTitleChange(this, `${this.repo}`);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 13d0e50..f8c03d2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-rule-editor_html';
@@ -102,9 +101,7 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRuleEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -158,8 +155,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // Check needed for test purposes.
     if (!this._originalRuleValues && this.rule) {
       // Observer _handleValueChange is called after the ready()
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index 9cc6357..9c3646a 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -197,7 +197,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -306,7 +306,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -371,7 +371,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -425,7 +425,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -482,7 +482,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -524,7 +524,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -571,7 +571,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 558037d..6fbee32 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -26,7 +26,6 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
@@ -79,7 +78,7 @@
 
 @customElement('gr-change-list-item')
 export class GrChangeListItem extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -126,8 +125,8 @@
   reporting: ReportingService = appContext.reportingService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index acf71c3..5570d26 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -27,7 +27,6 @@
   let element;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 42741fa..34572c5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -20,7 +20,6 @@
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-view_html';
@@ -62,9 +61,7 @@
 }
 
 @customElement('gr-change-list-view')
-export class GrChangeListView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrChangeListView extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -121,8 +118,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
   }
 
@@ -144,7 +141,7 @@
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    this.async(() => fireTitleChange(this, this._query));
+    setTimeout(() => fireTitleChange(this, this._query));
 
     this.restApiService
       .getPreferences()
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 f26cd46..6eb3bc1 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
@@ -21,7 +21,6 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list_html';
@@ -74,9 +73,7 @@
 }
 @customElement('gr-change-list')
 export class GrChangeList extends ChangeTableMixin(
-  KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))
-  )
+  KeyboardShortcutMixin(LegacyElementMixin(PolymerElement))
 ) {
   static get template() {
     return htmlTemplate;
@@ -176,8 +173,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -187,6 +184,12 @@
       });
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   /**
    * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
    * events must be scoped to a component level (e.g. `enter`) in order to not
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index f320296..a6f3d85 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
@@ -32,9 +31,7 @@
 }
 
 @customElement('gr-create-change-help')
-class GrCreateChangeHelp extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrCreateChangeHelp extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index 1ee8cb5..ed4c968 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
@@ -44,9 +43,7 @@
 }
 
 @customElement('gr-create-commands-dialog')
-export class GrCreateCommandsDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateCommandsDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index e53f68b..5c263bb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-destination-dialog_html';
@@ -44,8 +43,8 @@
 }
 
 @customElement('gr-create-destination-dialog')
-export class GrCreateDestinationDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrCreateDestinationDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index a34bd63..3b1c61e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -23,7 +23,6 @@
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
 import '../gr-user-header/gr-user-header';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dashboard-view_html';
@@ -79,9 +78,7 @@
 }
 
 @customElement('gr-dashboard-view')
-export class GrDashboardView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDashboardView extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -130,8 +127,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
     this.addEventListener('reload', e => {
       e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index a5de72b..165306e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -17,13 +17,12 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
-import {isHidden} from '../../../test/test-utils.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 import {createAccountWithId} from '../../../test/test-data-generators.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest, stubRestApi, isHidden} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dashboard-view');
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index 35a2a7f..ec24800 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -18,7 +18,6 @@
 import '../../../styles/dashboard-header-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-header_html';
@@ -28,9 +27,7 @@
 
 /** @extends PolymerElement */
 @customElement('gr-repo-header')
-class GrRepoHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrRepoHeader extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index cfee0cd..c4406cd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/dashboard-header-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-user-header_html';
@@ -32,9 +31,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-user-header')
-export class GrUserHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrUserHeader extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 87b09c7..dcd1d93 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -30,7 +30,6 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-actions_html';
@@ -338,8 +337,7 @@
 }
 
 @customElement('gr-change-actions')
-export class GrChangeActions
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+export class GrChangeActions extends LegacyElementMixin(PolymerElement)
   implements GrChangeActionsElement {
   static get template() {
     return htmlTemplate;
@@ -2083,7 +2081,7 @@
             }
 
             if (attemptsRemaining) {
-              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+              setTimeout(check, AWAIT_CHANGE_TIMEOUT_MS);
             } else {
               resolve(false);
             }
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
index e71c086..3d91a06 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -18,10 +18,9 @@
 import '../../../test/common-test-setup-karma.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import './gr-change-metadata.js';
-import {resetPlugins} from '../../../test/test-utils.js';
+import {resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const testHtmlPlugin = document.createElement('dom-module');
 testHtmlPlugin.innerHTML = `
@@ -87,7 +86,6 @@
   }
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('deleteVote').returns(Promise.resolve({ok: true}));
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index ef4d323..44974ce 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -33,7 +33,6 @@
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-metadata_html';
@@ -67,7 +66,7 @@
   ServerInfo,
   TopicName,
 } from '../../../types/common';
-import {assertNever} from '../../../utils/common-util';
+import {assertNever, unique} from '../../../utils/common-util';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
 import {appContext} from '../../../services/app-context';
@@ -78,7 +77,15 @@
   DisplayRules,
 } from '../../../utils/change-metadata-util';
 import {fireEvent} from '../../../utils/event-util';
-import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
+import {
+  EditRevisionInfo,
+  notUndefined,
+  ParsedChangeInfo,
+} from '../../../types/types';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -118,9 +125,7 @@
 }
 
 @customElement('gr-change-metadata')
-export class GrChangeMetadata extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrChangeMetadata extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -206,16 +211,22 @@
   @property({type: Boolean})
   _isNewChangeSummaryUiEnabled = false;
 
+  @property({type: Object})
+  queryTopic?: AutocompleteQuery;
+
   flagsService = appContext.flagsService;
 
   restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   /** @override */
   ready() {
     super.ready();
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
       KnownExperimentId.NEW_CHANGE_SUMMARY_UI
     );
+    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
   }
 
   @observe('change.labels')
@@ -567,6 +578,10 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'metadata',
+      toState: this._showAllSections ? 'Show all' : 'Show less',
+    });
   }
 
   /**
@@ -672,6 +687,18 @@
     provider.init();
     return provider;
   }
+
+  _getTopicSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarTopic(input)
+      .then(response =>
+        (response ?? [])
+          .map(change => change.topic)
+          .filter(notUndefined)
+          .filter(unique)
+          .map(topic => ({name: topic, value: topic}))
+      );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 59bf8ad..4f5e6a6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -363,6 +363,8 @@
             read-only="[[_topicReadOnly]]"
             on-changed="_handleTopicChanged"
             show-as-edit-pencil="[[_isNewChangeSummaryUiEnabled]]"
+            autocomplete="true"
+            query="[[queryTopic]]"
           ></gr-editable-label>
         </template>
       </span>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index adc7fd3..0b65f53 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -20,7 +20,6 @@
 import '../../shared/gr-label/gr-label';
 import '../../shared/gr-label-info/gr-label-info';
 import '../../shared/gr-limited-text/gr-limited-text';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-requirements_html';
@@ -58,9 +57,7 @@
 }
 
 @customElement('gr-change-requirements')
-class GrChangeRequirements extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrChangeRequirements extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -94,6 +91,8 @@
     KnownExperimentId.NEW_CHANGE_SUMMARY_UI
   );
 
+  private readonly reporting = appContext.reportingService;
+
   _computeShowWip(change: ChangeInfo) {
     return change.work_in_progress;
   }
@@ -193,6 +192,10 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'optional labels',
+      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
+    });
   }
 
   _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
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 8322f3c..6e20f03 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
@@ -77,6 +77,8 @@
   @property()
   category?: CommentTabState;
 
+  private readonly reporting = appContext.reportingService;
+
   static get styles() {
     return [
       sharedStyles,
@@ -131,6 +133,9 @@
   private handleClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
     fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
       commentTab: this.category,
     });
@@ -380,7 +385,7 @@
   }
 
   private onChipClick(state: ChecksTabState) {
-    fireShowPrimaryTab(this, PrimaryTab.CHECKS, true, {
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
       checksTab: state,
     });
   }
@@ -427,7 +432,7 @@
                 !!draftCount ||
                 !!countUnresolvedComments}
               >
-                No Comments</span
+                No comments</span
               ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 category=${CommentTabState.DRAFTS}
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 a7f5ea7..aded3b9 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
@@ -45,7 +45,6 @@
 import '../gr-upload-help-dialog/gr-upload-help-dialog';
 import '../../checks/gr-checks-tab';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-view_html';
@@ -111,6 +110,7 @@
   ChangeId,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
+  BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
@@ -166,9 +166,9 @@
   fireEvent,
   firePageError,
   fireDialogChange,
+  fireTitleChange,
 } from '../../../utils/event-util';
 import {KnownExperimentId} from '../../../services/flags/flags';
-import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
@@ -250,7 +250,7 @@
 
 @customElement('gr-change-view')
 export class GrChangeView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -605,20 +605,6 @@
   }
 
   /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
-      this._handleToggleChangeStar(e as CustomKeyboardEvent)
-    );
-  }
-
-  /** @override */
-  disconnectedCallback() {
-    this.disconnected$.next();
-    super.disconnectedCallback();
-  }
-
-  /** @override */
   created() {
     super.created();
 
@@ -651,8 +637,11 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleChangeStar = this._throttleWrap(e =>
+      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    );
     this._getServerConfig().then(config => {
       this._serverConfig = config;
       this._replyDisabled = false;
@@ -719,8 +708,8 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    this.disconnected$.next();
     this.unlisten(window, 'scroll', '_handleScroll');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     this.cancelDebouncer(DEBOUNCER_REPLY_OVERLAY_REFIT);
@@ -729,6 +718,7 @@
     if (this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
     }
+    super.disconnectedCallback();
   }
 
   get messagesList(): GrMessagesList | null {
@@ -822,11 +812,14 @@
     }
     const tabName = tabs[activeIndex].dataset['name'];
     if (scrollIntoView) {
-      paperTabs.scrollIntoView();
+      paperTabs.scrollIntoView({block: 'center'});
     }
     if (paperTabs.selected !== activeIndex) {
+      // paperTabs.selected is undefined during rendering
+      if (paperTabs.selected !== undefined) {
+        this.reporting.reportInteraction('show-tab', {tabName});
+      }
       paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
     }
     return tabName;
   }
@@ -1383,7 +1376,7 @@
 
     this._sendShowChangeEvent();
 
-    this.async(() => {
+    setTimeout(() => {
       if (this.viewState.scrollTop) {
         document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
       } else {
@@ -1495,10 +1488,10 @@
       if (this.viewState.showReplyDialog) {
         this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
         // TODO(kaspern@): Find a better signal for when to call center.
-        this.async(() => {
+        setTimeout(() => {
           this.$.replyOverlay.center();
         }, 100);
-        this.async(() => {
+        setTimeout(() => {
           this.$.replyOverlay.center();
         }, 1000);
         this.set('viewState.showReplyDialog', false);
@@ -1769,7 +1762,7 @@
     GerritNav.navigateToChange(
       this._change,
       latestPatchNum,
-      this._patchRange.patchNum
+      this._patchRange.patchNum as BasePatchSetNum
     );
   }
 
@@ -1899,6 +1892,7 @@
   }
 
   _openReplyDialog(section?: FocusTarget) {
+    if (!this._change) return;
     this.$.replyOverlay.open().finally(() => {
       // the following code should be executed no matter open succeed or not
       this._resetReplyOverlayFocusStops();
@@ -2050,7 +2044,7 @@
             }
           );
         }
-        return false;
+        return true;
       }
     );
   }
@@ -2227,10 +2221,11 @@
         }
       });
 
-    // Resolves when the project config has loaded.
-    const projectConfigLoaded = detailCompletes.then(() =>
-      this._getProjectConfig()
-    );
+    // Resolves when the project config has successfully loaded.
+    const projectConfigLoaded = detailCompletes.then(success => {
+      if (!success) return Promise.resolve();
+      return this._getProjectConfig();
+    });
     allDataPromises.push(projectConfigLoaded);
 
     // Resolves when change comments have loaded (comments, drafts and robot
@@ -2594,7 +2589,7 @@
       return;
     }
 
-    this._updateCheckTimerHandle = this.async(() => {
+    this._updateCheckTimerHandle = window.setTimeout(() => {
       assertIsDefined(this._change, '_change');
       const change = this._change;
       fetchChangeUpdates(change, this.restApiService).then(result => {
@@ -2650,7 +2645,7 @@
 
   _cancelUpdateCheckTimer() {
     if (this._updateCheckTimerHandle) {
-      this.cancelAsync(this._updateCheckTimerHandle);
+      window.clearTimeout(this._updateCheckTimerHandle);
     }
     this._updateCheckTimerHandle = null;
   }
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 08c04e8..feb61cf 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
@@ -149,7 +149,7 @@
       overflow-x: hidden;
     }
     .relatedChanges {
-      flex: 1 1 auto;
+      flex: 0 1 auto;
       overflow: hidden;
       padding: var(--spacing-l) 0;
     }
@@ -171,6 +171,9 @@
       overflow: hidden;
       position: relative; /* for arrowToCurrentChange to have position:absolute and be hidden */
     }
+    .emptySpace {
+      flex-grow: 1;
+    }
     .commitContainer {
       display: flex;
       flex-direction: column;
@@ -575,6 +578,7 @@
                 </div>
               </template>
             </div>
+            <div class="emptySpace"></div>
           </div>
         </div>
       </div>
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 ae446cd..b4e39f5 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
@@ -38,6 +38,7 @@
 import 'lodash/lodash';
 import {
   stubRestApi,
+  SinonSpyMember,
   TestKeyboardShortcutBinder,
 } from '../../../test/test-utils';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -64,6 +65,7 @@
 import {
   AccountId,
   ApprovalInfo,
+  BasePatchSetNum,
   ChangeId,
   ChangeInfo,
   CommitId,
@@ -87,11 +89,7 @@
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {AppElementChangeViewParams} from '../../gr-app-types';
-import {
-  SinonFakeTimers,
-  SinonSpy,
-  SinonStubbedMember,
-} from 'sinon/pkg/sinon-esm';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {
@@ -100,7 +98,6 @@
   UIDraft,
   UIRobot,
 } from '../../../utils/comment-util';
-import 'lodash/lodash';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -110,11 +107,6 @@
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
 
-type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
-  Parameters<F>,
-  ReturnType<F>
->;
-
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
 
@@ -435,7 +427,7 @@
     };
     element._patchRange = {
       patchNum: 3 as PatchSetNum,
-      basePatchNum: 1 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
@@ -451,7 +443,7 @@
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as PatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
@@ -472,7 +464,7 @@
     };
     element._patchRange = {
       patchNum: 3 as PatchSetNum,
-      basePatchNum: 1 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffBaseAgainstLeft(
@@ -490,7 +482,7 @@
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as PatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
@@ -509,7 +501,7 @@
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as PatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
@@ -1534,7 +1526,7 @@
 
     element._initialLoadComplete = true;
 
-    value.basePatchNum = 1 as PatchSetNum;
+    value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as PatchSetNum;
     element._paramsChanged(value);
     assert.isFalse(reloadStub.calledTwice);
@@ -1567,7 +1559,7 @@
 
     element._initialLoadComplete = true;
 
-    value.basePatchNum = 1 as PatchSetNum;
+    value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as PatchSetNum;
     element._paramsChanged(value);
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
@@ -1848,7 +1840,7 @@
     };
     sinon.stub(element, '_getEdit').callsFake(() =>
       Promise.resolve({
-        base_patch_set_number: 1 as PatchSetNum,
+        base_patch_set_number: 1 as BasePatchSetNum,
         commit: {...editCommit},
         base_revision: 'abc',
         ref: 'some/ref' as GitRef,
@@ -1991,7 +1983,6 @@
   });
 
   test('revert dialog opened with revert param', done => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     const awaitPluginsLoadedStub = sinon
       .stub(getPluginLoader(), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
@@ -2334,18 +2325,11 @@
     });
 
     suite('update checks', () => {
+      let clock: SinonFakeTimers;
       let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
-      let asyncStub: SinonStubbedMember<typeof element.async>;
       setup(() => {
+        clock = sinon.useFakeTimers();
         startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
-        asyncStub = sinon.stub(element, 'async').callsFake(f => {
-          // Only fire the async callback one time.
-          if (asyncStub.callCount > 1) {
-            return 1;
-          }
-          f.call(element);
-          return 1;
-        });
         element._change = {
           ...createChange(),
           revisions: createRevisions(1),
@@ -2389,14 +2373,14 @@
           ...createServerInfo(),
           change: {...createChangeConfig(), update_delay: 12345},
         };
+        clock.tick(12345 * 1000);
         await flush();
 
         assert.equal(startUpdateCheckTimerSpy.callCount, 2);
         assert.isTrue(getChangeDetailStub.called);
-        assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
       });
 
-      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+      test('_startUpdateCheckTimer out-of-date shows an alert', async () => {
         stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
@@ -2407,15 +2391,18 @@
           })
         );
 
+        let alertMessage = 'alert not fired';
         element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'A newer patch set has been uploaded');
-          done();
+          alertMessage = e.detail.message;
         });
         element._serverConfig = {
           ...createServerInfo(),
           change: {...createChangeConfig(), update_delay: 12345},
         };
+        clock.tick(12345 * 1000);
+        await flush();
 
+        assert.equal(alertMessage, 'A newer patch set has been uploaded');
         assert.equal(startUpdateCheckTimerSpy.callCount, 1);
       });
 
@@ -2435,13 +2422,14 @@
           ...createServerInfo(),
           change: {...createChangeConfig(), update_delay: 12345},
         };
+        clock.tick(12345 * 1000 * 2);
         await flush();
 
         // No toast, instead a second call to _startUpdateCheckTimer().
         assert.equal(startUpdateCheckTimerSpy.callCount, 2);
       });
 
-      test('_startUpdateCheckTimer new status shows an alert', done => {
+      test('_startUpdateCheckTimer new status shows an alert', async () => {
         stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
@@ -2453,17 +2441,21 @@
           })
         );
 
+        let alertMessage = 'alert not fired';
         element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'This change has been merged');
-          done();
+          alertMessage = e.detail.message;
         });
         element._serverConfig = {
           ...createServerInfo(),
           change: {...createChangeConfig(), update_delay: 12345},
         };
+        clock.tick(12345 * 1000);
+        await flush();
+
+        assert.equal(alertMessage, 'This change has been merged');
       });
 
-      test('_startUpdateCheckTimer new messages shows an alert', done => {
+      test('_startUpdateCheckTimer new messages shows an alert', async () => {
         stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
@@ -2473,17 +2465,19 @@
             current_revision: 'rev1' as CommitId,
           })
         );
+
+        let alertMessage = 'alert not fired';
         element.addEventListener('show-alert', e => {
-          assert.equal(
-            e.detail.message,
-            'There are new messages on this change'
-          );
-          done();
+          alertMessage = e.detail.message;
         });
         element._serverConfig = {
           ...createServerInfo(),
           change: {...createChangeConfig(), update_delay: 12345},
         };
+        clock.tick(12345 * 1000);
+        await flush();
+
+        assert.equal(alertMessage, 'There are new messages on this change');
       });
     });
 
@@ -2562,15 +2556,9 @@
         createAppElementChangeViewParams()
       )
     );
-    assert.isFalse(
-      callCompute(
-        {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
-        createAppElementChangeViewParams()
-      )
-    );
     assert.isTrue(
       callCompute(
-        {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
+        {basePatchNum: 1 as BasePatchSetNum, patchNum: EditPatchSetNum},
         createAppElementChangeViewParams()
       )
     );
@@ -2600,7 +2588,7 @@
     const edit: EditInfo = {
       ref: 'ref/test/abc' as GitRef,
       base_revision: 'abc',
-      base_patch_set_number: 1 as PatchSetNum,
+      base_patch_set_number: 1 as BasePatchSetNum,
       commit: {...editCommit},
       fetch: {},
     };
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 18bd3a0..0b148b7 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-commit-info_html';
@@ -31,9 +30,7 @@
 }
 
 @customElement('gr-commit-info')
-export class GrCommitInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCommitInfo extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 10563ee..df99471 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
@@ -42,7 +41,7 @@
  */
 @customElement('gr-confirm-abandon-dialog')
 export class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 2f33858..5aa5e8b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html';
@@ -29,8 +28,8 @@
 }
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrConfirmCherrypickConflictDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 225e3e9..3475e45 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html';
@@ -38,6 +37,7 @@
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {fireEvent} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -74,8 +74,8 @@
 }
 
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrConfirmCherrypickDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
@@ -264,10 +264,12 @@
 
   _handlecherryPickSingleChangeClicked() {
     this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+    fireEvent(this, 'iron-resize');
   }
 
   _handlecherryPickTopicClicked() {
     this._cherryPickType = CherryPickType.TOPIC;
+    fireEvent(this, 'iron-resize');
   }
 
   @observe('changeStatus', 'commitNum', 'commitMessage')
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
index 4de395c..6c0099e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -46,11 +46,12 @@
     }
     .cherryPickTopicLayout {
       display: flex;
+      align-items: center;
+      margin-bottom: var(--spacing-m);
     }
     .cherryPickSingleChange,
     .cherryPickTopic {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     .cherry-pick-topic-message {
       margin-bottom: var(--spacing-m);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 5e95e66..9da7ea2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -17,7 +17,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-move-dialog_html';
@@ -31,7 +30,7 @@
 
 @customElement('gr-confirm-move-dialog')
 export class GrConfirmMoveDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index b2b6e61..aa8745a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
@@ -49,9 +48,7 @@
 }
 
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmRebaseDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index e10b12b..a785afd 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-dialog_html';
@@ -41,9 +40,7 @@
 }
 
 @customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmRevertDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
index 9e1256f..609947d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
@@ -29,8 +28,8 @@
 const CHANGE_SUBJECT_LIMIT = 50;
 
 @customElement('gr-confirm-revert-submission-dialog')
-export class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrConfirmRevertSubmissionDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index df0678ee..7130907 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -21,7 +21,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/shared-styles';
 import '../gr-thread-list/gr-thread-list';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-submit-dialog_html';
@@ -37,9 +36,7 @@
   };
 }
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmSubmitDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
deleted file mode 100644
index e175fda..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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-confirm-submit-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
-
-suite('gr-file-list-header tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element._initialised = true;
-  });
-
-  test('display', () => {
-    element.action = {label: 'my-label'};
-    element.change = {
-      subject: 'my-subject',
-      revisions: {},
-    };
-    flush();
-    const header = element.shadowRoot
-        .querySelector('.header');
-    assert.equal(header.textContent.trim(), 'my-label');
-
-    const message = element.shadowRoot
-        .querySelector('.main p');
-    assert.notEqual(message.textContent.length, 0);
-    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
-  });
-
-  test('_computeUnresolvedCommentsWarning', () => {
-    const change = {unresolved_comment_count: 1};
-    assert.equal(element._computeUnresolvedCommentsWarning(change),
-        'Heads Up! 1 unresolved comment.');
-
-    const change2 = {unresolved_comment_count: 2};
-    assert.equal(element._computeUnresolvedCommentsWarning(change2),
-        'Heads Up! 2 unresolved comments.');
-  });
-
-  test('_computeHasChangeEdit', () => {
-    const change = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 'edit',
-        },
-      },
-      unresolved_comment_count: 0,
-    };
-
-    assert.equal(element._computeHasChangeEdit(change), true);
-
-    const change2 = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 2,
-        },
-      },
-    };
-    assert.equal(element._computeHasChangeEdit(change2), false);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
new file mode 100644
index 0000000..e9f3019
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright (C) 2018 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 {createChange, createRevision} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../test/test-utils';
+import {PatchSetNum} from '../../../types/common';
+import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+
+suite('gr-confirm-submit-dialog tests', () => {
+  let element: GrConfirmSubmitDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element._initialised = true;
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {
+      ...createChange(),
+      subject: 'my-subject',
+      revisions: {},
+    };
+    flush();
+    const header = queryAndAssert(element, '.header');
+    assert.equal(header.textContent!.trim(), 'my-label');
+
+    const message = queryAndAssert(element, '.main p');
+    assert.isNotEmpty(message.textContent);
+    assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
+  });
+
+  test('_computeUnresolvedCommentsWarning', () => {
+    const change = {...createChange(), unresolved_comment_count: 1};
+    assert.equal(
+      element._computeUnresolvedCommentsWarning(change),
+      'Heads Up! 1 unresolved comment.'
+    );
+
+    const change2 = {...createChange(), unresolved_comment_count: 2};
+    assert.equal(
+      element._computeUnresolvedCommentsWarning(change2),
+      'Heads Up! 2 unresolved comments.'
+    );
+  });
+
+  test('_computeHasChangeEdit', () => {
+    const change = {
+      ...createChange(),
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          ...createRevision(),
+          _number: 'edit' as PatchSetNum,
+        },
+      },
+      unresolved_comment_count: 0,
+    };
+
+    assert.isTrue(element._computeHasChangeEdit(change));
+
+    const change2 = {
+      ...createChange(),
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          ...createRevision(),
+          _number: 2 as PatchSetNum,
+        },
+      },
+    };
+    assert.isFalse(element._computeHasChangeEdit(change2));
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index ab1e4e6..0aae46c 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
@@ -38,9 +37,7 @@
 }
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDownloadDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 146b3e2..6726055 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -25,7 +25,6 @@
 import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list-header_html';
@@ -46,6 +45,7 @@
   ServerInfo,
   RevisionInfo,
   NumericChangeId,
+  BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
@@ -75,7 +75,7 @@
 
 @customElement('gr-file-list-header')
 export class GrFileListHeader extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -151,7 +151,7 @@
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 
   @property({type: String})
   filesExpanded?: FilesExpandedState;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index c2947a6..91e0c7e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -28,7 +28,6 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-file-status-chip/gr-file-status-chip';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list_html';
@@ -171,7 +170,7 @@
 
 @customElement('gr-file-list')
 export class GrFileList extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -356,8 +355,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -405,10 +404,11 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    this.$.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.cancelDebouncer(DEBOUNCER_LOADING_CHANGE);
+    super.disconnectedCallback();
   }
 
   /**
@@ -1778,7 +1778,7 @@
    */
   _reportRenderedRow(index: number) {
     if (index === this._shownFiles.length - 1) {
-      this.async(() => {
+      setTimeout(() => {
         this.reporting.timeEndWithAverage(
           RENDER_TIMING_LABEL,
           RENDER_AVG_TIMING_LABEL,
@@ -1816,6 +1816,16 @@
   _computeTruncatedPath(path: string) {
     return computeTruncatedPath(path);
   }
+
+  _getOldPath(file: NormalizedFileInfo) {
+    // The gr-endpoint-decorator is waiting until all gr-endpoint-param
+    // values are updated.
+    // The old_path property is undefined for added files, and the
+    // gr-endpoint-param value bound to file.old_path is never updates.
+    // As a results, the gr-endpoint-decorator doesn't work for added files.
+    // As a workaround, this method returns null instead of undefined.
+    return file.old_path ?? null;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index c57a2d5..2078d1a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -375,6 +375,8 @@
                 </gr-endpoint-param>
                 <gr-endpoint-param name="path" value="[[file.__path]]">
                 </gr-endpoint-param>
+                <gr-endpoint-param name="oldPath" value="[[_getOldPath(file)]]">
+                </gr-endpoint-param>
               </gr-endpoint-decorator>
             </template>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 80d8729..2eee60e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {listenOnce} from '../../../test/test-utils.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
@@ -26,7 +25,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {TestKeyboardShortcutBinder, stubRestApi, spyRestApi} from '../../../test/test-utils.js';
+import {TestKeyboardShortcutBinder, stubRestApi, spyRestApi, listenOnce} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
 import {createChangeComments} from '../../../test/test-data-generators.js';
@@ -80,7 +79,6 @@
 
   suite('basic tests', () => {
     setup(done => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
       stubRestApi('getPreferences').returns(Promise.resolve({}));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index b50bff4..55c505a 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-included-in-dialog_html';
@@ -31,9 +30,7 @@
 }
 
 @customElement('gr-included-in-dialog')
-export class GrIncludedInDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrIncludedInDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 60a6058..7ea2075 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-score-row_html';
@@ -56,9 +55,7 @@
 }
 
 @customElement('gr-label-score-row')
-export class GrLabelScoreRow extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelScoreRow extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 661cd1a..0258fc9 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
@@ -16,7 +16,6 @@
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-scores_html';
@@ -38,11 +37,10 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
+import {Execution} from '../../../constants/reporting';
 
 @customElement('gr-label-scores')
-export class GrLabelScores extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelScores extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -104,7 +102,10 @@
       }
     }
     const stringVal = `${numberValue}`;
-    this.reporting.reportExecution('label-value-not-found', {value: stringVal});
+    this.reporting.reportExecution(Execution.REACHABLE_CODE, {
+      value: stringVal,
+      id: 'label-value-not-found',
+    });
     return stringVal;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index f913459..28ce47e 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -23,7 +23,6 @@
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-voting-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
@@ -41,6 +40,7 @@
   ChangeMessageId,
   PatchSetNum,
   AccountInfo,
+  BasePatchSetNum,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -69,7 +69,7 @@
   id: ChangeMessageId;
 }
 
-interface ChangeMessage extends ChangeMessageInfo {
+export interface ChangeMessage extends ChangeMessageInfo {
   // TODO(TS): maybe should be an enum instead
   type: string;
   expanded: boolean;
@@ -84,9 +84,7 @@
 }
 
 @customElement('gr-message')
-export class GrMessage extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMessage extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -204,8 +202,8 @@
     this.addEventListener('click', e => this._handleClick(e));
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService.getConfig().then(config => {
       this.config = config;
     });
@@ -306,7 +304,7 @@
       const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
       if (isNaN(Number(match[1])))
         throw new Error('invalid patchnum in message');
-      basePatchNum = Number(match[1]) as PatchSetNum;
+      basePatchNum = Number(match[1]) as BasePatchSetNum;
       patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
     } else {
       // Message is of the form "Commit Message was updated" or "Patchset X
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 8d03e32..45f4406 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -61,7 +61,7 @@
       font-weight: var(--font-weight-bold);
     }
     .message {
-      --gr-formatted-text-prose-max-width: 80ch;
+      --gr-formatted-text-prose-max-width: 120ch;
     }
     .collapsed .message {
       max-width: none;
@@ -288,7 +288,8 @@
                 items="[[update.reviewers]]"
                 as="reviewer"
               >
-                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
+                <gr-account-chip account="[[reviewer]]" change="[[change]]">
+                </gr-account-chip>
               </template>
             </div>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
deleted file mode 100644
index 3f4e13a..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ /dev/null
@@ -1,577 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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-message.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {createChange, createRevisions} from '../../../test/test-data-generators.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-message');
-
-suite('gr-message tests', () => {
-  let element;
-
-  suite('when admin and logged in', () => {
-    setup(done => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
-      stubRestApi('getConfig').returns(Promise.resolve({}));
-      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
-      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('reply event', done => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      element.addEventListener('reply', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        done();
-      });
-      flush();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
-    });
-
-    test('can see delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
-    });
-
-    test('delete change message', done => {
-      element.changeNum = 314159;
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      element.addEventListener('change-message-deleted', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
-        done();
-      });
-      flush();
-      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
-      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
-    });
-
-    test('autogenerated prefix hiding', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('reviewer message treated as autogenerated', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('batch reviewer message treated as autogenerated', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('tag that is not autogenerated prefix does not hide', () => {
-      element.message = {
-        tag: 'something',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isFalse(element.hidden);
-    });
-
-    test('reply button hidden unless logged in', () => {
-      const message = {
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
-    });
-
-    test('_computeShowOnBehalfOf', () => {
-      const message = {
-        message: '...',
-        expanded: false,
-      };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author._account_id = 123456;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      message.updated_by = message.author;
-      delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-    });
-
-    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-      test(`${label} ignored for color voting`, () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: `Patch Set 1: ${label}+1`,
-        };
-        assert.isNotOk(
-            element.root.querySelector('.negativeVote'));
-        assert.isNotOk(
-            element.root.querySelector('.positiveVote'));
-      });
-    });
-
-    test('clicking on date link fires event', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        id: '47c43261_55aa2c41',
-        expanded: false,
-      };
-      flush();
-      const stub = sinon.stub();
-      element.addEventListener('message-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
-    });
-
-    suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
-      let navStub;
-      setup(() => {
-        element.change = {...createChange(), revisions: createRevisions(4)};
-        navStub = sinon.stub(GerritNav, 'navigateToChange');
-      });
-
-      test('Patchset 1 navigates to Base', () => {
-        element.message = {
-          message: 'Uploaded patch set 1.',
-        };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly(element.change, 1,
-            'PARENT'));
-      });
-
-      test('Patchset X navigates to X vs X - 1', () => {
-        element.message = {
-          message: 'Uploaded patch set 2.',
-        };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly(element.change, 2, 1));
-
-        element.message = {
-          message: 'Uploaded patch set 200.',
-        };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly(element.change, 200, 199));
-      });
-
-      test('Commit message updated', () => {
-        element.message = {
-          message: 'Commit message updated.',
-        };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
-      });
-
-      test('Merged patchset change message', () => {
-        element.message = {
-          message: 'abcd↵3 is the latest approved patch-set.↵abc',
-        };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
-      });
-    });
-
-    suite('compute messages', () => {
-      test('empty', () => {
-        assert.equal(element._computeMessageContent(true, '', ''), '');
-        assert.equal(element._computeMessageContent(false, '', ''), '');
-      });
-
-      test('new patchset', () => {
-        const original = 'Uploaded patch set 1.';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        let actual = element._computeMessageContent(true, original, tag);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, tag);
-        assert.equal(actual, original);
-      });
-
-      test('new patchset rebased', () => {
-        const original = 'Patch Set 27: Patch Set 26 was rebased';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, tag);
-        assert.equal(actual, expected);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        actual = element._computeMessageContent(false, original, tag);
-        assert.equal(actual, expected);
-      });
-
-      test('ready for review', () => {
-        const original = 'Patch Set 1:\n\nThis change is ready for review.';
-        const tag = undefined;
-        const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, tag);
-        assert.equal(actual, expected);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        actual = element._computeMessageContent(false, original, tag);
-        assert.equal(actual, expected);
-      });
-
-      test('vote', () => {
-        const original = 'Patch Set 1: Code-Style+1';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
-        assert.equal(actual, expected);
-      });
-
-      test('comments', () => {
-        const original = 'Patch Set 1:\n\n(3 comments)';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
-        assert.equal(actual, expected);
-      });
-    });
-
-    test('votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('Uploaded patch set X', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Uploaded patch set 1:' +
-         'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('removed votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Commit-Queue': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[1].classList.contains('removed'));
-      assert.isTrue(scoreChips[2].classList.contains('removed'));
-    });
-
-    test('false negative vote', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 0);
-    });
-  });
-
-  suite('when not logged in', () => {
-    setup(done => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
-      stubRestApi('getConfig').returns(Promise.resolve({}));
-      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('reply and delete button should be hidden', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isTrue(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-  });
-
-  suite('patchset comment summary', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.message = {id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3'};
-    });
-
-    test('single patchset comment posted', () => {
-      const threads = [{
-        comments: [{
-          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
-          patch_set: 1,
-          id: 'e365b138_bed65caa',
-          updated: '2020-05-15 13:35:56.000000000',
-          message: 'testing the load',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          collapsed: false,
-        }],
-        patchNum: 1,
-        path: '/PATCHSET_LEVEL',
-        rootId: 'e365b138_bed65caa',
-      }];
-      assert.equal(element._computeMessageContentCollapsed(
-          '', undefined, threads), 'testing the load');
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
-    });
-
-    test('single patchset comment with reply', () => {
-      const threads = [{
-        comments: [{
-          patch_set: 1,
-          id: 'e365b138_bed65caa',
-          updated: '2020-05-15 13:35:56.000000000',
-          message: 'testing the load',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          collapsed: false,
-        }, {
-          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
-          patch_set: 1,
-          id: 'd6efcc85_4cbbb6f4',
-          in_reply_to: 'e365b138_bed65caa',
-          updated: '2020-05-15 16:55:28.000000000',
-          message: 'n',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          __draft: true,
-          collapsed: true,
-        }],
-        patchNum: 1,
-        path: '/PATCHSET_LEVEL',
-        rootId: 'e365b138_bed65caa',
-      }];
-      assert.equal(element._computeMessageContentCollapsed(
-          '', undefined, threads), 'n');
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
-    });
-  });
-
-  suite('when logged in but not admin', () => {
-    setup(async () => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-      stubRestApi('getConfig').returns(Promise.resolve({}));
-      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
-      element = basicFixture.instantiate();
-      await flush();
-    });
-
-    test('can see reply but not delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-
-    test('reply button shown when message is updated', () => {
-      element.message = undefined;
-      flush();
-      let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      // We don't even expect the button to show up in the DOM when the message
-      // is undefined.
-      assert.isNotOk(replyEl);
-
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'not empty',
-        _revision_number: 1,
-        expanded: true,
-      };
-      flush();
-      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      assert.isOk(replyEl);
-      assert.isFalse(replyEl.hidden);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
new file mode 100644
index 0000000..3652be9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -0,0 +1,676 @@
+/**
+ * @license
+ * Copyright (C) 2016 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-message';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  createChange,
+  createChangeMessage,
+  createComment,
+  createRevisions,
+} from '../../../test/test-data-generators';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrMessage} from './gr-message';
+import {
+  AccountId,
+  BasePatchSetNum,
+  ChangeMessageId,
+  EmailAddress,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewInputTag,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common.js';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  ChangeMessageDeletedEventDetail,
+  ReplyEventDetail,
+} from '../../../types/events.js';
+import {GrButton} from '../../shared/gr-button/gr-button.js';
+import {CommentSide} from '../../../constants/constants.js';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+
+const basicFixture = fixtureFromElement('gr-message');
+
+suite('gr-message tests', () => {
+  let element: GrMessage;
+
+  suite('when admin and logged in', () => {
+    setup(done => {
+      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply event', done => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      flush();
+      assert.isFalse(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      tap(queryAndAssert(element, '.replyBtn'));
+    });
+
+    test('can see delete button', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.changeNum = 314159 as NumericChangeId;
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      element.addEventListener(
+        'change-message-deleted',
+        (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+          assert.deepEqual(e.detail.message, element.message);
+          assert.isFalse(
+            (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+          );
+          done();
+        }
+      );
+      flush();
+      tap(queryAndAssert(element, '.deleteBtn'));
+      assert.isTrue(
+        (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+      );
+    });
+
+    test('autogenerated prefix hiding', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'autogenerated:gerrit:test' as ReviewInputTag,
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('reviewer message treated as autogenerated', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'autogenerated:gerrit:test' as ReviewInputTag,
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        ...createChangeMessage(),
+        type: 'REVIEWER_UPDATE',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'something' as ReviewInputTag,
+        expanded: false,
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        ...createChangeMessage(),
+        message: 'Uploaded patch set 1.',
+        expanded: false,
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        ...createChangeMessage(),
+        message: '...',
+        expanded: false,
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495 as AccountId};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495 as AccountId};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456 as AccountId;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
+
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
+        element.message = {
+          ...createChangeMessage(),
+          author: {},
+          expanded: false,
+          message: `Patch Set 1: ${label}+1`,
+        };
+        assert.isNotOk(query(element, '.negativeVote'));
+        assert.isNotOk(query(element, '.positiveVote'));
+      });
+    });
+
+    test('clicking on date link fires event', () => {
+      element.message = {
+        ...createChangeMessage(),
+        type: 'REVIEWER_UPDATE',
+        reviewer: {},
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        expanded: false,
+      };
+      flush();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = queryAndAssert(element, '.date');
+      assert.ok(dateEl);
+      tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
+    suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
+      let navStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+      setup(() => {
+        element.change = {...createChange(), revisions: createRevisions(4)};
+        navStub = sinon.stub(GerritNav, 'navigateToChange');
+      });
+
+      test('Patchset 1 navigates to Base', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 1.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            1 as PatchSetNum,
+            'PARENT' as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Patchset X navigates to X vs X - 1', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 2.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            2 as PatchSetNum,
+            1 as BasePatchSetNum
+          )
+        );
+
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 200.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            200 as PatchSetNum,
+            199 as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Commit message updated', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Commit message updated.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            4 as PatchSetNum,
+            3 as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Merged patchset change message', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'abcd↵3 is the latest approved patch-set.↵abc',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            4 as PatchSetNum,
+            3 as BasePatchSetNum
+          )
+        );
+      });
+    });
+
+    suite('compute messages', () => {
+      test('empty', () => {
+        assert.equal(
+          element._computeMessageContent(true, '', '' as ReviewInputTag),
+          ''
+        );
+        assert.equal(
+          element._computeMessageContent(false, '', '' as ReviewInputTag),
+          ''
+        );
+      });
+
+      test('new patchset', () => {
+        const original = 'Uploaded patch set 1.';
+        const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        assert.equal(actual, original);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, original);
+      });
+
+      test('new patchset rebased', () => {
+        const original = 'Patch Set 27: Patch Set 26 was rebased';
+        const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
+        const expected = 'Patch Set 26 was rebased';
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(actual, expected);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+
+      test('ready for review', () => {
+        const original = 'Patch Set 1:\n\nThis change is ready for review.';
+        const tag = undefined;
+        const expected = 'This change is ready for review.';
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(actual, expected);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+
+      test('vote', () => {
+        const original = 'Patch Set 1: Code-Style+1';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+
+      test('comments', () => {
+        const original = 'Patch Set 1:\n\n(3 comments)';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+    });
+
+    test('votes', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('Uploaded patch set X', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message:
+          'Uploaded patch set 1:' +
+          'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('removed votes', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Commit-Queue': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[1].classList.contains('removed'));
+      assert.isTrue(scoreChips[2].classList.contains('removed'));
+    });
+
+    test('false negative vote', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      element.labelExtremes = {};
+      const scoreChips = element.root!.querySelectorAll('.score');
+      assert.equal(scoreChips.length, 0);
+    });
+  });
+
+  suite('when not logged in', () => {
+    setup(done => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply and delete button should be hidden', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isTrue(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+  });
+
+  suite('patchset comment summary', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.message = {
+        ...createChangeMessage(),
+        id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
+      };
+    });
+
+    test('single patchset comment posted', () => {
+      const threads = [
+        {
+          comments: [
+            {
+              ...createComment(),
+              change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
+              patch_set: 1 as PatchSetNum,
+              id: 'e365b138_bed65caa' as UrlEncodedCommentId,
+              updated: '2020-05-15 13:35:56.000000000' as Timestamp,
+              message: 'testing the load',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              collapsed: false,
+            },
+          ],
+          patchNum: 1 as PatchSetNum,
+          path: '/PATCHSET_LEVEL',
+          rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
+          commentSide: CommentSide.REVISION,
+        },
+      ];
+      assert.equal(
+        element._computeMessageContentCollapsed('', undefined, threads),
+        'testing the load'
+      );
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
+    });
+
+    test('single patchset comment with reply', () => {
+      const threads = [
+        {
+          comments: [
+            {
+              ...createComment(),
+              patch_set: 1 as PatchSetNum,
+              id: 'e365b138_bed65caa' as UrlEncodedCommentId,
+              updated: '2020-05-15 13:35:56.000000000' as Timestamp,
+              message: 'testing the load',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              collapsed: false,
+            },
+            {
+              change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+              patch_set: 1 as PatchSetNum,
+              id: 'd6efcc85_4cbbb6f4' as UrlEncodedCommentId,
+              in_reply_to: 'e365b138_bed65caa' as UrlEncodedCommentId,
+              updated: '2020-05-15 16:55:28.000000000' as Timestamp,
+              message: 'n',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              __draft: true,
+              collapsed: true,
+            },
+          ],
+          patchNum: 1 as PatchSetNum,
+          path: '/PATCHSET_LEVEL',
+          rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
+          commentSide: CommentSide.REVISION,
+        },
+      ];
+      assert.equal(
+        element._computeMessageContentCollapsed('', undefined, threads),
+        'n'
+      );
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
+    });
+  });
+
+  suite('when logged in but not admin', () => {
+    setup(async () => {
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      await flush();
+    });
+
+    test('can see reply but not delete button', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isFalse(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+
+    test('reply button shown when message is updated', () => {
+      element.message = undefined;
+      flush();
+      let replyEl = query(element, '.replyActionContainer');
+      // We don't even expect the button to show up in the DOM when the message
+      // is undefined.
+      assert.isNotOk(replyEl);
+
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'not empty',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+      flush();
+      replyEl = queryAndAssert(element, '.replyActionContainer');
+      assert.isOk(replyEl);
+      assert.isFalse((replyEl as HTMLElement).hidden);
+    });
+  });
+});
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 bc3f167..16d9d39 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
@@ -19,7 +19,6 @@
 import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
@@ -214,7 +213,7 @@
 
 @customElement('gr-messages-list')
 export class GrMessagesList extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index d22c7d0..fd45eec 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -133,7 +133,6 @@
 
   suite('basic tests', () => {
     setup(() => {
-      stubRestApi('getConfig').returns(Promise.resolve({}));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve(comments));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -440,7 +439,6 @@
     let commentApiWrapper;
 
     setup(() => {
-      stubRestApi('getConfig').returns(Promise.resolve({}));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
index 7b698f1..49c2486 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
@@ -35,9 +35,6 @@
   href?: string;
 
   @property()
-  isCurrentChange = false;
-
-  @property()
   showSubmittableCheck = false;
 
   @property()
@@ -102,9 +99,6 @@
         .submittableCheck.submittable {
           display: inline;
         }
-        .arrowToCurrentChange {
-          position: absolute;
-        }
       `,
     ];
   }
@@ -113,13 +107,7 @@
     const change = this.change;
     if (!change) throw new Error('Missing change');
     const linkClass = this._computeLinkClass(change);
-    return html`<span
-        role="img"
-        class="arrowToCurrentChange"
-        aria-label="Arrow marking current change"
-        ?hidden=${!this.isCurrentChange}
-        >âž”</span
-      >
+    return html`
       <div class="changeContainer">
         <a href="${this.href}" class="${linkClass}"><slot></slot></a>
         ${this.showSubmittableCheck
@@ -137,7 +125,8 @@
               (${this._computeChangeStatus(change)})
             </span>`
           : ''}
-      </div> `;
+      </div>
+    `;
   }
 
   _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
index e921979..abac54c 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
@@ -21,7 +21,13 @@
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import {classMap} from 'lit-html/directives/class-map';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement, property, css, internalProperty} from 'lit-element';
+import {
+  customElement,
+  property,
+  css,
+  internalProperty,
+  TemplateResult,
+} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   SubmittedTogetherInfo,
@@ -42,7 +48,22 @@
 } from '../../../utils/change-util';
 
 /** What is the maximum number of shown changes in collapsed list? */
-const MAX_CHANGES_WHEN_COLLAPSED = 3;
+const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+
+export interface ChangeMarkersInList {
+  showCurrentChangeArrow: boolean;
+  showWhenCollapsed: boolean;
+  showTopArrow: boolean;
+  showBottomArrow: boolean;
+}
+
+export enum Section {
+  RELATED_CHANGES = 'related changes',
+  SUBMITTED_TOGETHER = 'submitted together',
+  SAME_TOPIC = 'same topic',
+  MERGE_CONFLICTS = 'merge conflicts',
+  CHERRY_PICKS = 'cherry picks',
+}
 
 @customElement('gr-related-changes-list-experimental')
 export class GrRelatedChangesListExperimental extends GrLitElement {
@@ -86,16 +107,34 @@
         section {
           margin-bottom: var(--spacing-m);
         }
+        gr-related-change {
+          display: flex;
+        }
+        .marker {
+          position: absolute;
+          margin-left: calc(-1 * var(--spacing-s));
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
       `,
     ];
   }
 
   render() {
-    let showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+    const sectionSize = this.sectionSizeFactory(
+      this.relatedChanges.length,
+      this.submittedTogether?.changes.length || 0,
+      this.sameTopicChanges.length,
+      this.conflictingChanges.length,
+      this.cherryPickChanges.length
+    );
+    const relatedChangesMarkersPredicate = this.markersPredicateFactory(
       this.relatedChanges.length,
       this.relatedChanges.findIndex(relatedChange =>
         this._changesEqual(relatedChange, this.change)
-      )
+      ),
+      sectionSize(Section.RELATED_CHANGES)
     );
     const connectedRevisions = this._computeConnectedRevisions(
       this.change,
@@ -109,26 +148,29 @@
       <gr-related-collapse
         title="Relation chain"
         .length=${this.relatedChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
         ${this.relatedChanges.map(
           (change, index) =>
-            html`<gr-related-change
-              class="${classMap({
-                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
-              })}"
-              .isCurrentChange="${this._changesEqual(change, this.change)}"
-              .change="${change}"
-              .connectedRevisions="${connectedRevisions}"
-              .href="${change?._change_number
-                ? GerritNav.getUrlForChangeById(
-                    change._change_number,
-                    change.project,
-                    change._revision_number as PatchSetNum
-                  )
-                : ''}"
-              .showChangeStatus=${true}
-              >${change.commit.subject}</gr-related-change
-            >`
+            html`${this.renderMarkers(
+                relatedChangesMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .connectedRevisions="${connectedRevisions}"
+                .href="${change?._change_number
+                  ? GerritNav.getUrlForChangeById(
+                      change._change_number,
+                      change.project,
+                      change._revision_number as PatchSetNum
+                    )
+                  : ''}"
+                .showChangeStatus=${true}
+                >${change.commit.subject}</gr-related-change
+              >`
         )}
       </gr-related-collapse>
     </section>`;
@@ -136,11 +178,12 @@
     const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
     const countNonVisibleChanges =
       this.submittedTogether?.non_visible_changes ?? 0;
-    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+    const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
       submittedTogetherChanges.length,
       submittedTogetherChanges.findIndex(relatedChange =>
         this._changesEqual(relatedChange, this.change)
-      )
+      ),
+      sectionSize(Section.SUBMITTED_TOGETHER)
     );
     const submittedTogetherSection = html`<section
       id="submittedTogether"
@@ -150,23 +193,27 @@
       <gr-related-collapse
         title="Submitted together"
         .length=${submittedTogetherChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
-            html`<gr-related-change
-              class="${classMap({
-                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
-              })}"
-              .isCurrentChange="${this._changesEqual(change, this.change)}"
-              .change="${change}"
-              .href="${GerritNav.getUrlForChangeById(
-                change._number,
-                change.project
-              )}"
-              .showSubmittableCheck=${true}
-              >${change.project}: ${change.branch}:
-              ${change.subject}</gr-related-change
-            >`
+            html`${this.renderMarkers(
+                submittedTogetherMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
+                    index
+                  ).showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                .showSubmittableCheck=${true}
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
         )}
       </gr-related-collapse>
       <div class="note" ?hidden=${!countNonVisibleChanges}>
@@ -174,9 +221,10 @@
       </div>
     </section>`;
 
-    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+    const sameTopicMarkersPredicate = this.markersPredicateFactory(
       this.sameTopicChanges.length,
-      -1
+      -1,
+      sectionSize(Section.SAME_TOPIC)
     );
     const sameTopicSection = html`<section
       id="sameTopic"
@@ -185,28 +233,33 @@
       <gr-related-collapse
         title="Same topic"
         .length=${this.sameTopicChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
-            html`<gr-related-change
-              class="${classMap({
-                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
-              })}"
-              .change="${change}"
-              .href="${GerritNav.getUrlForChangeById(
-                change._number,
-                change.project
-              )}"
-              >${change.project}: ${change.branch}:
-              ${change.subject}</gr-related-change
-            >`
+            html`${this.renderMarkers(
+                sameTopicMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
         )}
       </gr-related-collapse>
     </section>`;
 
-    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+    const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
       this.conflictingChanges.length,
-      -1
+      -1,
+      sectionSize(Section.MERGE_CONFLICTS)
     );
     const mergeConflictsSection = html`<section
       id="mergeConflicts"
@@ -215,27 +268,32 @@
       <gr-related-collapse
         title="Merge conflicts"
         .length=${this.conflictingChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
         ${this.conflictingChanges.map(
           (change, index) =>
-            html`<gr-related-change
-              class="${classMap({
-                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
-              })}"
-              .change="${change}"
-              .href="${GerritNav.getUrlForChangeById(
-                change._number,
-                change.project
-              )}"
-              >${change.subject}</gr-related-change
-            >`
+            html`${this.renderMarkers(
+                mergeConflictsMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.subject}</gr-related-change
+              >`
         )}
       </gr-related-collapse>
     </section>`;
 
-    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+    const cherryPicksMarkersPredicate = this.markersPredicateFactory(
       this.cherryPickChanges.length,
-      -1
+      -1,
+      sectionSize(Section.CHERRY_PICKS)
     );
     const cherryPicksSection = html`<section
       id="cherryPicks"
@@ -244,20 +302,24 @@
       <gr-related-collapse
         title="Cherry picks"
         .length=${this.cherryPickChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
-            html`<gr-related-change
-              class="${classMap({
-                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
-              })}"
-              .change="${change}"
-              .href="${GerritNav.getUrlForChangeById(
-                change._number,
-                change.project
-              )}"
-              >${change.branch}: ${change.subject}</gr-related-change
-            >`
+            html`${this.renderMarkers(
+                cherryPicksMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.branch}: ${change.subject}</gr-related-change
+              >`
         )}
       </gr-related-collapse>
     </section>`;
@@ -271,17 +333,140 @@
     </gr-endpoint-decorator>`;
   }
 
-  showWhenCollapsedPredicateFactory(length: number, highlightIndex: number) {
-    return (index: number) => {
-      if (highlightIndex === -1) return index < MAX_CHANGES_WHEN_COLLAPSED;
-      if (highlightIndex === 0) return index <= MAX_CHANGES_WHEN_COLLAPSED - 1;
+  sectionSizeFactory(
+    relatedChangesLen: number,
+    submittedTogetherLen: number,
+    sameTopicLen: number,
+    mergeConflictsLen: number,
+    cherryPicksLen: number
+  ) {
+    const calcDefaultSize = (length: number) =>
+      Math.min(length, DEFALT_NUM_CHANGES_WHEN_COLLAPSED);
+
+    const sectionSizes = [
+      {
+        section: Section.RELATED_CHANGES,
+        size: calcDefaultSize(relatedChangesLen),
+        len: relatedChangesLen,
+      },
+      {
+        section: Section.SUBMITTED_TOGETHER,
+        size: calcDefaultSize(submittedTogetherLen),
+        len: submittedTogetherLen,
+      },
+      {
+        section: Section.SAME_TOPIC,
+        size: calcDefaultSize(sameTopicLen),
+        len: sameTopicLen,
+      },
+      {
+        section: Section.MERGE_CONFLICTS,
+        size: calcDefaultSize(mergeConflictsLen),
+        len: mergeConflictsLen,
+      },
+      {
+        section: Section.CHERRY_PICKS,
+        size: calcDefaultSize(cherryPicksLen),
+        len: cherryPicksLen,
+      },
+    ];
+
+    const FILLER = 1; // space for header
+    let totalSize = sectionSizes.reduce(
+      (acc, val) => acc + val.size + (val.size !== 0 ? FILLER : 0),
+      0
+    );
+
+    const MAX_SIZE = 16;
+    for (let i = 0; i < sectionSizes.length; i++) {
+      if (totalSize >= MAX_SIZE) break;
+      const sizeObj = sectionSizes[i];
+      if (sizeObj.size === sizeObj.len) continue;
+      const newSize = Math.min(
+        MAX_SIZE - totalSize + sizeObj.size,
+        sizeObj.len
+      );
+      totalSize += newSize - sizeObj.size;
+      sizeObj.size = newSize;
+    }
+
+    return (section: Section) => {
+      const sizeObj = sectionSizes.find(sizeObj => sizeObj.section === section);
+      if (sizeObj) return sizeObj.size;
+      return DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+    };
+  }
+
+  markersPredicateFactory(
+    length: number,
+    highlightIndex: number,
+    numChangesShownWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED
+  ): (index: number) => ChangeMarkersInList {
+    const showWhenCollapsedPredicate = (index: number) => {
+      if (highlightIndex === -1) return index < numChangesShownWhenCollapsed;
+      if (highlightIndex === 0)
+        return index <= numChangesShownWhenCollapsed - 1;
       if (highlightIndex === length - 1)
-        return index >= length - MAX_CHANGES_WHEN_COLLAPSED;
+        return index >= length - numChangesShownWhenCollapsed;
+      let numBeforeHighlight = Math.floor(numChangesShownWhenCollapsed / 2);
+      let numAfterHighlight =
+        Math.floor(numChangesShownWhenCollapsed / 2) -
+        (numChangesShownWhenCollapsed % 2 ? 0 : 1);
+      numBeforeHighlight += Math.max(
+        highlightIndex + numAfterHighlight - length + 1,
+        0
+      );
+      numAfterHighlight -= Math.min(0, highlightIndex - numBeforeHighlight);
       return (
-        highlightIndex - MAX_CHANGES_WHEN_COLLAPSED + 2 <= index &&
-        index <= highlightIndex + MAX_CHANGES_WHEN_COLLAPSED - 2
+        highlightIndex - numBeforeHighlight <= index &&
+        index <= highlightIndex + numAfterHighlight
       );
     };
+    return (index: number) => {
+      return {
+        showCurrentChangeArrow:
+          highlightIndex !== -1 && index === highlightIndex,
+        showWhenCollapsed: showWhenCollapsedPredicate(index),
+        showTopArrow:
+          index >= 1 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index - 1),
+        showBottomArrow:
+          index <= length - 2 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index + 1),
+      };
+    };
+  }
+
+  renderMarkers(changeMarkers: ChangeMarkersInList) {
+    if (changeMarkers.showCurrentChangeArrow) {
+      return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        >âž”</span
+      >`;
+    }
+    if (changeMarkers.showTopArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed ancestors"
+        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
+      ></span> `;
+    }
+    if (changeMarkers.showBottomArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed descendants"
+        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
+      ></span> `;
+    }
+    return nothing;
   }
 
   reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
@@ -429,6 +614,11 @@
   @property()
   length = 0;
 
+  @property()
+  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+
+  private readonly reporting = appContext.reportingService;
+
   static get styles() {
     return [
       sharedStyles,
@@ -456,13 +646,25 @@
           width: 1.2em;
         }
         .collapsed ::slotted(gr-related-change.show-when-collapsed) {
-          display: flex;
+          visibility: visible;
+          height: auto;
         }
-        .collapsed ::slotted(gr-related-change) {
+        .collapsed ::slotted(.marker) {
+          display: block;
+        }
+        .show-all ::slotted(.marker) {
           display: none;
         }
+        /* keep width, so width of section and position of show all button
+         * are set according to width of all (even hidden) elements
+         */
+        .collapsed ::slotted(gr-related-change) {
+          visibility: hidden;
+          height: 0px;
+        }
         ::slotted(gr-related-change) {
-          display: flex;
+          visibility: visible;
+          height: auto;
         }
         gr-button iron-icon {
           color: inherit;
@@ -481,14 +683,14 @@
   render() {
     const title = html`<h4 class="title">${this.title}</h4>`;
 
-    const collapsible = this.length > MAX_CHANGES_WHEN_COLLAPSED;
+    const collapsible = this.length > this.numChangesWhenCollapsed;
     const items = html` <div
-      class="${!this.showAll && collapsible ? 'collapsed' : ''}"
+      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
     >
       <slot></slot>
     </div>`;
 
-    let button = nothing;
+    let button: TemplateResult | typeof nothing = nothing;
     if (collapsible) {
       if (this.showAll) {
         button = html`<gr-button link="" @click="${this.toggle}"
@@ -509,6 +711,10 @@
   private toggle(e: MouseEvent) {
     e.stopPropagation();
     this.showAll = !this.showAll;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts
new file mode 100644
index 0000000..6d8fe13
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental_test.ts
@@ -0,0 +1,158 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-related-changes-list-experimental';
+import {
+  ChangeMarkersInList,
+  GrRelatedChangesListExperimental,
+  Section,
+} from './gr-related-changes-list-experimental';
+
+const basicFixture = fixtureFromElement('gr-related-changes-list-experimental');
+
+suite('gr-related-changes-list-experimental', () => {
+  let element: GrRelatedChangesListExperimental;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('show when collapsed', () => {
+    function genBoolArray(
+      instructions: Array<{
+        len: number;
+        v: boolean;
+      }>
+    ) {
+      return instructions
+        .map(inst => Array.from({length: inst.len}, () => inst.v))
+        .reduce((acc, val) => acc.concat(val), []);
+    }
+
+    function checkShowWhenCollapsed(
+      expected: boolean[],
+      markersPredicate: (index: number) => ChangeMarkersInList,
+      msg: string
+    ) {
+      for (let i = 0; i < expected.length; i++) {
+        assert.equal(
+          markersPredicate(i).showWhenCollapsed,
+          expected[i],
+          `change on pos (${i}) ${msg}`
+        );
+      }
+    }
+
+    test('size 5', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 5);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 5, v: true},
+        {len: 3, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, size 10, size 5'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 5);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 5, v: false},
+        {len: 5, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, size 10, size 5'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 5);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 5, v: true},
+        {len: 5, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, size 10, size 5'
+      );
+    });
+
+    test('size 4', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 4);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 4, v: true},
+        {len: 4, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, len 10, size 4'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 4);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 6, v: false},
+        {len: 4, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, len 10, size 4'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 4);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 4, v: true},
+        {len: 6, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, len 10, size 4'
+      );
+    });
+  });
+
+  suite('section size', () => {
+    test('1 section', () => {
+      const sectionSize = element.sectionSizeFactory(20, 0, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 15);
+      const sectionSize2 = element.sectionSizeFactory(0, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('2 sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 11);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('many sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 3, 3, 3);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 3);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 1, 10, 1, 1);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 4);
+    });
+  });
+});
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 5705c4f..1f51138 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
@@ -19,7 +19,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-related-changes-list_html';
@@ -53,9 +52,7 @@
 }
 
 @customElement('gr-related-changes-list')
-export class GrRelatedChangesList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRelatedChangesList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -303,8 +300,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // We listen to `new-section-loaded` events to allow plugins to trigger
     // visibility computations, if their content or visibility changed.
     this.addEventListener('new-section-loaded', () =>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
deleted file mode 100644
index f54b819..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
+++ /dev/null
@@ -1,631 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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-related-changes-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromElement('gr-related-changes-list');
-
-suite('gr-related-changes-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('connected revisions', () => {
-    const change = {
-      revisions: {
-        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
-          _number: 1,
-        },
-        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
-          _number: 2,
-        },
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
-          _number: 7,
-        },
-        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
-          _number: 5,
-        },
-        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
-          _number: 6,
-        },
-        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
-          _number: 3,
-        },
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
-          _number: 4,
-        },
-      },
-    };
-    let patchNum = 7;
-    let relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-          parents: [
-            {
-              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-          parents: [
-            {
-              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-          parents: [
-            {
-              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
-            },
-          ],
-        },
-      },
-    ];
-
-    let connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-    ]);
-
-    patchNum = 4;
-    relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-          parents: [
-            {
-              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-          parents: [
-            {
-              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-          parents: [
-            {
-              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
-            },
-          ],
-        },
-      },
-    ];
-
-    connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      'af815dac54318826b7f1fa468acc76349ffc588e',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-    ]);
-  });
-
-  test('_changesEqual', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _number: 1};
-    const change3 = {change_id: 123, _number: 2};
-    const change4 = {change_id: 123, _change_number: 1};
-
-    assert.isTrue(element._changesEqual(change1, change1));
-    assert.isFalse(element._changesEqual(change1, change2));
-    assert.isFalse(element._changesEqual(change1, change3));
-    assert.isTrue(element._changesEqual(change2, change4));
-  });
-
-  test('_getChangeNumber', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _change_number: 1};
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
-  });
-
-  test('event for section loaded fires for each section ', () => {
-    const loadedStub = sinon.stub();
-    element.patchNum = 7;
-    element.change = {
-      change_id: 123,
-      status: 'NEW',
-    };
-    element.mergeable = true;
-    element.addEventListener('new-section-loaded', loadedStub);
-    stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-    stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-    stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-    stubRestApi('getChangeConflicts').returns(Promise.resolve());
-
-    return element.reload().then(() => {
-      assert.equal(loadedStub.callCount, 4);
-    });
-  });
-
-  suite('getChangeConflicts resolves undefined', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-      stubRestApi('getChangeConflicts').returns(Promise.resolve());
-    });
-
-    test('_conflicts are an empty array', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.equal(element._conflicts.length, 0);
-    });
-  });
-
-  suite('get conflicts tests', () => {
-    let element;
-    let conflictsStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
-      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
-      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
-      conflictsStub = stubRestApi('getChangeConflicts').returns(
-          Promise.resolve());
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
-  test('_calculateHasParent', () => {
-    const changeId = 123;
-    const relatedChanges = [];
-
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 123});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 234});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        true);
-  });
-
-  suite('hidden attribute and update event', () => {
-    const changes = [{
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    }];
-
-    test('clear and empties', () => {
-      element._relatedResponse = {changes};
-      element._submittedTogether = {changes};
-      element._conflicts = changes;
-      element._cherryPicks = changes;
-      element._sameTopic = changes;
-
-      element.hidden = false;
-      element.clear();
-      assert.isTrue(element.hidden);
-      assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether.changes.length, 0);
-      assert.equal(element._conflicts.length, 0);
-      assert.equal(element._cherryPicks.length, 0);
-      assert.equal(element._sameTopic.length, 0);
-    });
-
-    test('update fires', () => {
-      const updateHandler = sinon.stub();
-      element.addEventListener('update', updateHandler);
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 0}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged(
-          {}, {changes: ['test'], non_visible_changes: 0}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 1}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-    });
-
-    suite('hiding and unhiding', () => {
-      test('related response', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({changes}, {}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('submitted together', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {changes}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('conflicts', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, changes, [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('cherrypicks', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], changes, []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('same topic', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], [], changes);
-        assert.isFalse(element.hidden);
-      });
-    });
-  });
-
-  test('_computeChangeURL uses GerritNav', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
-    element._computeChangeURL(123, 'abc/def', 12);
-    assert.isTrue(getUrlStub.called);
-  });
-
-  suite('submitted together changes', () => {
-    const change = {
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    };
-
-    test('_computeSubmittedTogetherClass', () => {
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass(undefined),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: []}),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: [{}]}),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 0,
-          }),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 1,
-          }),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [{}],
-            non_visible_changes: 1,
-          }),
-          '');
-    });
-
-    test('no submitted together changes', () => {
-      flush();
-      assert.include(element.$.submittedTogether.className, 'hidden');
-    });
-
-    test('no non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change]};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNull(element.shadowRoot
-          .querySelector('.note'));
-    });
-
-    test('no visible submitted together changes', () => {
-      // Technically this should never happen, but worth asserting the logic.
-      element._submittedTogether = {changes: [], non_visible_changes: 1};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText.trim(),
-          '(+ 1 non-visible change)');
-    });
-
-    test('visible and non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change], non_visible_changes: 2};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText.trim(),
-          '(+ 2 non-visible changes)');
-    });
-  });
-});
-
-suite('gr-related-changes-list plugin tests', () => {
-  let element;
-
-  setup(() => {
-    resetPlugins();
-    element = basicFixture.instantiate();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('endpoint params', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url1.html');
-    getPluginLoader().loadPlugins([]);
-    flush(() => {
-      assert.strictEqual(hookEl.plugin, plugin);
-      assert.strictEqual(hookEl.change, element.change);
-      done();
-    });
-  });
-
-  test('hiding and unhiding', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-
-    // No changes, and no plugin. The element is still hidden.
-    element._resultsChanged({}, {}, [], [], []);
-    assert.isTrue(element.hidden);
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url2.html');
-    getPluginLoader().loadPlugins([]);
-    flush(() => {
-      // No changes, and plugin without hidden attribute. So it's visible.
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // No changes, but plugin with true hidden attribute. So it's invisible.
-      hookEl.hidden = true;
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-
-      // No changes, and plugin with false hidden attribute. So it's visible.
-      hookEl.hidden = false;
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // Hiding triggered by plugin itself
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isTrue(element.hidden);
-
-      // Unhiding triggered by plugin itself
-      hookEl.hidden = false;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      // Hiding plugin keeps list visible, if there are changes
-      hookEl.hidden = false;
-      element._sameTopic = ['test'];
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      done();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
new file mode 100644
index 0000000..c5b3a58
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -0,0 +1,853 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 {ChangeStatus} from '../../../constants/constants';
+import '../../../test/common-test-setup-karma';
+import {
+  createChange,
+  createCommit,
+  createCommitInfoWithRequiredCommit,
+  createParsedChange,
+  createRelatedChangeAndCommitInfo,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RelatedChangeAndCommitInfo,
+  RepoName,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
+import './gr-related-changes-list';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {
+  query,
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrRelatedChangesList} from './gr-related-changes-list';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginApi} from '../../../api/plugin';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromElement('gr-related-changes-list');
+
+suite('gr-related-changes-list tests', () => {
+  let element: GrRelatedChangesList;
+
+  setup(() => {
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    element = basicFixture.instantiate();
+  });
+
+  test('connected revisions', () => {
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      revisions: {
+        e3c6d60783bfdec9ebae7dcfec4662360433449e: createRevision(1),
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': createRevision(2),
+        bf7884d695296ca0c91702ba3e2bc8df0f69a907: createRevision(7),
+        b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3: createRevision(5),
+        d6bcee67570859ccb684873a85cf50b1f0e96fda: createRevision(6),
+        cc960918a7f90388f4a9e05753d0f7b90ad44546: createRevision(3),
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': createRevision(4),
+      },
+    };
+    let patchNum = 7 as PatchSetNum;
+    let relatedChanges: RelatedChangeAndCommitInfo[] = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'subject1',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'subject2',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'subject3',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+          ),
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' as CommitId,
+              subject: 'subject4',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+          ),
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' as CommitId,
+              subject: 'subject5',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+          ),
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' as CommitId,
+              subject: 'subject6',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4 as PatchSetNum;
+    relatedChanges = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b'
+          ),
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+          ),
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'af815dac54318826b7f1fa468acc76349ffc588e'
+          ),
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  test('_changesEqual', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    const change3: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 2 as NumericChangeId,
+    };
+    const change4: RelatedChangeAndCommitInfo = {
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+      _change_number: 1 as NumericChangeId,
+    };
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  test('event for section loaded fires for each section ', () => {
+    const loadedStub = sinon.stub();
+    element.patchNum = 7 as PatchSetNum;
+    element.change = {
+      ...createParsedChange(),
+      change_id: '123' as ChangeId,
+      status: ChangeStatus.NEW,
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('getChangeConflicts resolves undefined', () => {
+    let element: GrRelatedChangesList;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('_conflicts are an empty array', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.equal(element._conflicts.length, 0);
+    });
+  });
+
+  suite('get conflicts tests', () => {
+    let element: GrRelatedChangesList;
+    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      conflictsStub = stubRestApi('getChangeConflicts').returns(
+        Promise.resolve(undefined)
+      );
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('_calculateHasParent', () => {
+    const changeId = '123' as ChangeId;
+    const relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '234' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), true);
+  });
+
+  suite('hidden attribute and update event', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        project: 'foo/bar' as RepoName,
+        change_id: 'Ideadbeef' as ChangeId,
+        status: ChangeStatus.NEW,
+      },
+    ];
+    const relatedChanges: RelatedChangeAndCommitInfo[] = [
+      {
+        ...createCommitInfoWithRequiredCommit(),
+        project: 'foo/bar' as RepoName,
+        change_id: 'Ideadbeef' as ChangeId,
+        commit: {
+          ...createCommit(),
+          commit: 'deadbeef' as CommitId,
+          parents: [
+            {
+              commit: 'abc123' as CommitId,
+              subject: 'abc123',
+            },
+          ],
+          subject: 'do that thing',
+        },
+        _change_number: 12345 as NumericChangeId,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: ChangeStatus.NEW,
+      },
+    ];
+
+    test('clear and empties', () => {
+      element._relatedResponse = {changes: relatedChanges};
+      element._submittedTogether = {
+        changes,
+        non_visible_changes: 0,
+      };
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether?.changes.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic?.length, 0);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sinon.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        changes
+      );
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+        {changes: []},
+        {changes, non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 1},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
+
+    suite('hiding and unhiding', () => {
+      test('related response', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: relatedChanges},
+          {changes: [], non_visible_changes: 0},
+          [],
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('submitted together', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes, non_visible_changes: 0},
+          [],
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('conflicts', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          changes,
+          [],
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('cherrypicks', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          [],
+          changes,
+          []
+        );
+        assert.isFalse(element.hidden);
+      });
+
+      test('same topic', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged(
+          {changes: []},
+          {changes: [], non_visible_changes: 0},
+          [],
+          [],
+          changes
+        );
+        assert.isFalse(element.hidden);
+      });
+    });
+  });
+
+  test('_computeChangeURL uses GerritNav', () => {
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
+    element._computeChangeURL(
+      123 as NumericChangeId,
+      'abc/def' as RepoName,
+      12 as PatchSetNum
+    );
+    assert.isTrue(getUrlStub.called);
+  });
+
+  suite('submitted together changes', () => {
+    const change: ChangeInfo = {
+      ...createChange(),
+      project: 'foo/bar' as RepoName,
+      change_id: 'Ideadbeef' as ChangeId,
+      status: ChangeStatus.NEW,
+    };
+
+    test('_computeSubmittedTogetherClass', () => {
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass(undefined),
+        'hidden'
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [],
+          non_visible_changes: 0,
+        }),
+        'hidden'
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [change],
+          non_visible_changes: 0,
+        }),
+        ''
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [],
+          non_visible_changes: 0,
+        }),
+        'hidden'
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [],
+          non_visible_changes: 1,
+        }),
+        ''
+      );
+      assert.strictEqual(
+        element._computeSubmittedTogetherClass({
+          changes: [],
+          non_visible_changes: 1,
+        }),
+        ''
+      );
+    });
+
+    test('no submitted together changes', () => {
+      flush();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 0};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isUndefined(query(element, '.note'));
+    });
+
+    test('no visible submitted together changes', () => {
+      // Technically this should never happen, but worth asserting the logic.
+      element._submittedTogether = {changes: [], non_visible_changes: 1};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.strictEqual(
+        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
+        '(+ 1 non-visible change)'
+      );
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flush();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.strictEqual(
+        queryAndAssert<HTMLDivElement>(element, '.note').innerText.trim(),
+        '(+ 2 non-visible changes)'
+      );
+    });
+  });
+
+  suite('gr-related-changes-list plugin tests', () => {
+    let element: GrRelatedChangesList;
+
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('endpoint params', done => {
+      element.change = {...createParsedChange(), labels: {}};
+      interface RelatedChangesListGrEndpointDecorator
+        extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+      }
+      let hookEl: RelatedChangesListGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('related-changes-section')
+            .getLastAttached()
+            .then(el => (hookEl = el as RelatedChangesListGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url1.html'
+      );
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        done();
+      });
+    });
+  });
+
+  test('hiding and unhiding', done => {
+    element.change = {...createParsedChange(), labels: {}};
+    let hookEl: HTMLElement;
+    let plugin;
+
+    // No changes, and no plugin. The element is still hidden.
+    element._resultsChanged(
+      {changes: []},
+      {changes: [], non_visible_changes: 0},
+      [],
+      [],
+      []
+    );
+    assert.isTrue(element.hidden);
+    pluginApi.install(
+      p => {
+        plugin = p;
+        plugin
+          .hook('related-changes-section')
+          .getLastAttached()
+          .then(el => (hookEl = el));
+      },
+      '0.1',
+      'http://some/plugins/url2.html'
+    );
+    getPluginLoader().loadPlugins([]);
+    flush(() => {
+      // No changes, and plugin without hidden attribute. So it's visible.
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+
+      // No changes, but plugin with true hidden attribute. So it's invisible.
+      hookEl.hidden = true;
+
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isTrue(element.hidden);
+
+      // No changes, and plugin with false hidden attribute. So it's visible.
+      hookEl.hidden = false;
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        []
+      );
+      assert.isFalse(element.hidden);
+
+      // Hiding triggered by plugin itself
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(
+        new CustomEvent('new-section-loaded', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(element.hidden);
+
+      // Unhiding triggered by plugin itself
+      hookEl.hidden = false;
+      hookEl.dispatchEvent(
+        new CustomEvent('new-section-loaded', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isFalse(element.hidden);
+
+      // Hiding plugin keeps list visible, if there are changes
+      hookEl.hidden = false;
+      const change = createChange();
+      element._sameTopic = [change];
+      element._resultsChanged(
+        {changes: []},
+        {changes: [], non_visible_changes: 0},
+        [],
+        [],
+        [change]
+      );
+      assert.isFalse(element.hidden);
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(
+        new CustomEvent('new-section-loaded', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isFalse(element.hidden);
+
+      done();
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index b09510ba..c16ed12 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -16,12 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {resetPlugins} from '../../../test/test-utils.js';
+import {resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import './gr-reply-dialog.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 const pluginApi = _testOnly_initGerritPluginApi();
@@ -75,7 +74,6 @@
     changeNum = 42;
     patchNum = 1;
 
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getAccount').returns(Promise.resolve({_account_id: 42}));
 
     element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index a8e89b6..a735147 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -26,7 +26,6 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reply-dialog_html';
@@ -96,7 +95,7 @@
   assertNever,
   containsAll,
 } from '../../../utils/common-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -107,7 +106,6 @@
   getApprovalInfo,
   getMaxAccounts,
 } from '../../../utils/label-util';
-import {isUnresolved} from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {fireAlert, fireEvent, fireServerError} from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
@@ -173,7 +171,7 @@
 
 @customElement('gr-reply-dialog')
 export class GrReplyDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -395,8 +393,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
     this._getAccount().then(account => {
       if (account) this._account = account;
@@ -429,8 +427,9 @@
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_STORE);
+    super.disconnectedCallback();
   }
 
   open(focusTarget?: FocusTarget) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 0575239..466f7bd 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -65,7 +65,6 @@
     changeNum = 42;
     patchNum = 1;
 
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getAccount').returns(Promise.resolve({}));
     stubRestApi('getChange').returns(Promise.resolve({}));
     stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 30931c3..ec895d9 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reviewer-list_html';
@@ -45,9 +44,7 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 
 @customElement('gr-reviewer-list')
-export class GrReviewerList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrReviewerList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index f77f736..83b7794 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -28,7 +28,6 @@
     element = basicFixture.instantiate();
     element.serverConfig = {};
 
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index f0ec7cd..b7592b0 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-thread-list_html';
@@ -57,9 +56,7 @@
 }
 
 @customElement('gr-thread-list')
-export class GrThreadList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrThreadList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
index d52da80..161bc2f 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-shell-command/gr-shell-command';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-upload-help-dialog_html';
@@ -32,9 +31,7 @@
 const PREFERRED_FETCH_COMMAND_ORDER = ['checkout', 'cherry pick', 'pull'];
 
 @customElement('gr-upload-help-dialog')
-export class GrUploadHelpDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrUploadHelpDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -69,8 +66,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService
       .getLoggedIn()
       .then(loggedIn =>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
index d44cbb0..d633de5 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
@@ -43,7 +43,10 @@
             the files.
           </p>
           <template is="dom-if" if="[[_fetchCommand]]">
-            <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
+            <gr-shell-command
+              class="fetch-command"
+              command="[[_fetchCommand]]"
+            ></gr-shell-command>
           </template>
         </li>
         <li>
@@ -51,14 +54,20 @@
             Update the local commit with your modifications using the following
             command.
           </p>
-          <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
+          <gr-shell-command
+            class="commit-command"
+            command="[[_commitCommand]]"
+          ></gr-shell-command>
           <p>
             Leave the "Change-Id:" line of the commit message as is.
           </p>
         </li>
         <li>
           <p>Push the updated commit to Gerrit.</p>
-          <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+          <gr-shell-command
+            class="push-command"
+            command="[[_pushCommand]]"
+          ></gr-shell-command>
         </li>
         <li>
           <p>Refresh this page to view the the update.</p>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index ef2430c..bf37e20 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -24,6 +24,7 @@
   query,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
+import '@polymer/paper-tooltip/paper-tooltip';
 import {
   Category,
   CheckRun,
@@ -35,14 +36,17 @@
 import {sharedStyles} from '../../styles/shared-styles';
 import {RunResult} from '../../services/checks/checks-model';
 import {
-  hasCompleted,
+  allResults,
   hasCompletedWithoutResults,
+  hasResultsOf,
   iconForCategory,
-  isRunning,
 } from '../../services/checks/checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
+import {charsOnly, pluralize} from '../../utils/string-util';
+import {fireRunSelectionReset} from './gr-checks-util';
+import {ChecksTabState} from '../../types/events';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -229,6 +233,7 @@
   }
 
   renderLink(link: Link) {
+    const tooltipText = link.tooltip ?? 'Link to details';
     return html`
       <a href="${link.url}" target="_blank">
         <iron-icon
@@ -236,6 +241,7 @@
           class="launch"
           icon="gr-icons:launch"
         ></iron-icon>
+        <paper-tooltip offset="5">${tooltipText}</paper-tooltip>
       </a>
     `;
   }
@@ -303,8 +309,31 @@
   @property()
   runs: CheckRun[] = [];
 
+  @property()
+  tabState?: ChecksTabState;
+
+  /**
+   * How many runs are selected in the runs panel?
+   * If 0, then the `runs` property contains all the runs there are.
+   * If >0, then it only contains the data of certain selected runs.
+   */
+  @property()
+  selectedRunsCount = 0;
+
+  /**
+   * This is the current state of whether a section is expanded or not. As long
+   * as isSectionExpandedByUser is false this will be computed by a default rule
+   * on every render.
+   */
   private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
 
+  /**
+   * Keeps track of whether the user intentionally changed the expansion state.
+   * Once this is true the default rule for showing a section expanded or not
+   * is not applied anymore.
+   */
+  private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>();
+
   static get styles() {
     return [
       sharedStyles,
@@ -313,17 +342,32 @@
           display: block;
           padding: var(--spacing-xl);
         }
-        input#filterInput {
+        .filterDiv {
+          display: flex;
           margin-top: var(--spacing-s);
+          align-items: center;
+        }
+        .filterDiv input#filterInput {
           padding: var(--spacing-s) var(--spacing-m);
           min-width: 400px;
         }
+        .filterDiv .selection {
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        .filterDiv iron-icon.filter {
+          color: var(--selected-foreground);
+        }
+        .filterDiv gr-button.reset {
+          margin: calc(0px - var(--spacing-s)) var(--spacing-l);
+        }
         .categoryHeader {
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
-          text-transform: capitalize;
           cursor: default;
         }
+        .categoryHeader .title {
+          text-transform: capitalize;
+        }
         .categoryHeader .expandIcon {
           width: var(--line-height-h3);
           height: var(--line-height-h3);
@@ -345,6 +389,10 @@
         .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
+        .categoryHeader .filtered {
+          color: var(--deemphasized-text-color);
+        }
+        .collapsed .noResultsMessage,
         .collapsed table {
           display: none;
         }
@@ -352,8 +400,14 @@
           border-bottom: 1px solid var(--border-color);
           padding-bottom: var(--spacing-m);
         }
-        .noCompleted {
-          margin-top: var(--spacing-l);
+        .noResultsMessage {
+          width: 100%;
+          max-width: 1280px;
+          margin-top: var(--spacing-m);
+          background-color: var(--background-color-primary);
+          box-shadow: var(--elevation-level-1);
+          padding: var(--spacing-s)
+            calc(20px + var(--spacing-l) + var(--spacing-m) + var(--spacing-s));
         }
         table.resultsTable {
           width: 100%;
@@ -371,62 +425,115 @@
     ];
   }
 
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState') && this.tabState) {
+      const {statusOrCategory, checkName} = this.tabState;
+      if (
+        statusOrCategory &&
+        statusOrCategory !== RunStatus.RUNNING &&
+        statusOrCategory !== RunStatus.RUNNABLE
+      ) {
+        let cat = statusOrCategory.toString().toLowerCase();
+        if (statusOrCategory === RunStatus.COMPLETED) cat = 'success';
+        this.scrollElIntoView(`.categoryHeader .${cat}`);
+      } else if (checkName) {
+        this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
+      }
+    }
+  }
+
+  scrollElIntoView(selector: string) {
+    this.updateComplete.then(() => {
+      let el = this.shadowRoot?.querySelector(selector);
+      // <gr-result-row> has display:contents and cannot be scrolled into view
+      // itself. Thus we are preferring to scroll the first child into view.
+      el = el?.shadowRoot?.firstElementChild ?? el;
+      el?.scrollIntoView({block: 'center'});
+    });
+  }
+
   render() {
     return html`
       <div><h2 class="heading-2">Results</h2></div>
-      ${this.renderFilter()} ${this.renderNoCompleted()}
-      ${this.renderSection(Category.ERROR)}
+      ${this.renderFilter()} ${this.renderSection(Category.ERROR)}
       ${this.renderSection(Category.WARNING)}
       ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
     `;
   }
 
   renderFilter() {
-    if (this.runs.length === 0) return;
+    if (this.selectedRunsCount === 0 && allResults(this.runs).length <= 3) {
+      if (this.filterRegExp.source.length > 0) {
+        this.filterRegExp = new RegExp('');
+      }
+      return;
+    }
     return html`
-      <input
-        id="filterInput"
-        type="text"
-        placeholder="Filter results by regular expression"
-        @input="${this.onInput}"
-      />
+      <div class="filterDiv">
+        <input
+          id="filterInput"
+          type="text"
+          placeholder="Filter results by regular expression"
+          @input="${this.onInput}"
+        />
+        <div class="selection">
+          ${this.renderSelectionFilter()}
+        </div>
+      </div>
     `;
   }
 
+  renderSelectionFilter() {
+    const count = this.selectedRunsCount;
+    if (count === 0) return;
+    return html`
+      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
+      <span>Filtered by ${pluralize(count, 'run')}</span>
+      <gr-button link class="reset" @click="${this.handleClick}"
+        >Reset View</gr-button
+      >
+    `;
+  }
+
+  handleClick() {
+    this.filterRegExp = new RegExp('');
+    fireRunSelectionReset(this);
+  }
+
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
-  renderNoCompleted() {
-    if (this.runs.some(hasCompleted)) return;
-    let text = 'No results';
-    if (this.runs.some(isRunning)) {
-      text = 'Checks are running ...';
-    }
-    return html`<div class="noCompleted">${text}</div>`;
-  }
-
   renderSection(category: Category | 'SUCCESS') {
     const catString = category.toString().toLowerCase();
     let runs = this.runs;
     if (category === 'SUCCESS') {
-      runs = runs
-        .filter(hasCompletedWithoutResults)
-        .filter(r => this.filterRegExp.test(r.checkName));
+      runs = runs.filter(hasCompletedWithoutResults);
     } else {
-      runs = runs.filter(r =>
-        (r.results ?? []).some(res => res.category === category)
-      );
+      runs = runs.filter(r => hasResultsOf(r, category));
     }
-    if (runs.length === 0) return;
-    const expanded = this.isSectionExpanded.get(category) ?? true;
+    const all = runs.reduce((allResults: RunResult[], run) => {
+      return [...allResults, ...this.computeRunResults(category, run)];
+    }, []);
+    const filtered = all.filter(
+      result =>
+        this.filterRegExp.test(result.checkName) ||
+        this.filterRegExp.test(result.summary)
+    );
+    let expanded = this.isSectionExpanded.get(category);
+    const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
+    if (!expandedByUser || expanded === undefined) {
+      expanded = all.length > 0;
+      this.isSectionExpanded.set(category, expanded);
+    }
     const expandedClass = expanded ? 'expanded' : 'collapsed';
-    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+    const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
     return html`
       <div class="${expandedClass}">
         <h3
-          class="categoryHeader heading-3"
+          class="categoryHeader ${catString} heading-3"
           @click="${() => this.toggleExpanded(category)}"
         >
           <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
@@ -434,50 +541,83 @@
             icon="gr-icons:${iconForCategory(category)}"
             class="statusIcon ${catString}"
           ></iron-icon>
-          ${catString}
+          <span class="title">${catString}</span>
+          <span class="count">${this.renderCount(all, filtered)}</span>
         </h3>
-        <table class="resultsTable">
-          <thead>
-            <tr class="headerRow">
-              <th class="iconCol"></th>
-              <th class="nameCol">Run</th>
-              <th class="summaryCol">Summary</th>
-              <th class="expanderCol"></th>
-            </tr>
-          </thead>
-          <tbody>
-            ${runs.map(run =>
-              category === 'SUCCESS'
-                ? this.renderSuccessfulRun(run)
-                : this.renderRun(category, run)
-            )}
-          </tbody>
-        </table>
+        ${this.renderResults(all, filtered)}
       </div>
     `;
   }
 
+  renderResults(all: RunResult[], filtered: RunResult[]) {
+    if (all.length === 0 && this.selectedRunsCount > 0) {
+      return html`<div class="noResultsMessage">
+        No results for this filtered view
+      </div>`;
+    }
+    if (all.length === 0) {
+      return html`<div class="noResultsMessage">No results</div>`;
+    }
+    if (filtered.length === 0) {
+      return html`<div class="noResultsMessage">
+        No results match the regular expression
+      </div>`;
+    }
+    return html`
+      <table class="resultsTable">
+        <thead>
+          <tr class="headerRow">
+            <th class="iconCol"></th>
+            <th class="nameCol">Run</th>
+            <th class="summaryCol">Summary</th>
+            <th class="expanderCol"></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${filtered.map(
+            result => html`
+              <gr-result-row
+                class="${charsOnly(result.checkName)}"
+                .result="${result}"
+              ></gr-result-row>
+            `
+          )}
+        </tbody>
+      </table>
+    `;
+  }
+
+  renderCount(all: RunResult[], filtered: RunResult[]) {
+    if (this.selectedRunsCount > 0) {
+      return html`<span class="filtered"> - filtered</span>`;
+    }
+    if (all.length === filtered.length) {
+      return html`(${all.length})`;
+    } else {
+      return html`(${filtered.length} of ${all.length})`;
+    }
+  }
+
   toggleExpanded(category: Category | 'SUCCESS') {
-    const expanded = this.isSectionExpanded.get(category) ?? true;
+    const expanded = this.isSectionExpanded.get(category);
+    assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
+    this.isSectionExpandedByUser.set(category, true);
     this.requestUpdate();
   }
 
-  renderRun(category: Category, run: CheckRun) {
-    return html`${run.results
-      ?.filter(result => result.category === category)
-      .filter(
-        result =>
-          this.filterRegExp.test(run.checkName) ||
-          this.filterRegExp.test(result.summary)
-      )
-      .map(
-        result =>
-          html`<gr-result-row .result="${{...run, ...result}}"></gr-result-row>`
-      )}`;
+  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
+    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+    return (
+      run.results
+        ?.filter(result => result.category === category)
+        .map(result => {
+          return {...run, ...result};
+        }) ?? []
+    );
   }
 
-  renderSuccessfulRun(run: CheckRun) {
+  computeSuccessfulRunResult(run: CheckRun): RunResult {
     const adaptedRun: RunResult = {
       category: Category.INFO, // will not be used, but is required
       summary: run.statusDescription ?? '',
@@ -501,7 +641,7 @@
         },
       ];
     }
-    return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`;
+    return adaptedRun;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 31f17724..1d661c3 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -21,6 +21,7 @@
   customElement,
   internalProperty,
   property,
+  PropertyValues,
   query,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
@@ -45,28 +46,8 @@
 } from '../../services/checks/checks-model';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
-
-export interface RunSelectedEventDetail {
-  checkName: string;
-}
-
-export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'run-selected': RunSelectedEvent;
-  }
-}
-
-function fireRunSelected(target: EventTarget, checkName: string) {
-  target.dispatchEvent(
-    new CustomEvent('run-selected', {
-      detail: {checkName},
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
+import {fireRunSelected} from './gr-checks-util';
+import {ChecksTabState} from '../../types/events';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends GrLitElement {
@@ -130,21 +111,14 @@
         }
         /* Additional 'div' for increased specificity. */
         div.chip.selected {
-          border: 1px solid var(--selected-foreground);
+          border: 1px solid var(--selected-background);
           background-color: var(--selected-background);
           padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
         }
-        div.chip.deselected {
-          border: 1px solid var(--gray-foreground);
-          background-color: transparent;
-          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
-        }
-        div.chip.selected iron-icon {
+        div.chip.selected .name,
+        div.chip.selected iron-icon.filter {
           color: var(--selected-foreground);
         }
-        div.chip.deselected iron-icon {
-          color: var(--gray-foreground);
-        }
         .chip.selected gr-button.action,
         .chip.deselected gr-button.action {
           display: none;
@@ -188,7 +162,7 @@
   render() {
     if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
 
-    const icon = this.selected ? 'filter' : iconForRun(this.run);
+    const icon = iconForRun(this.run);
     const classes = {
       chip: true,
       [icon]: true,
@@ -200,6 +174,7 @@
     return html`
       <div @click="${this.handleChipClick}" class="${classMap(classes)}">
         <div class="left">
+          ${this.renderFilterIcon()}
           <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
@@ -218,6 +193,13 @@
     `;
   }
 
+  renderFilterIcon() {
+    if (!this.selected) return;
+    return html`
+      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
+    `;
+  }
+
   /**
    * For RUNNING we also want to render an icon representing the worst result
    * that has been reported until now - if there are any results already.
@@ -259,6 +241,9 @@
   @property()
   selectedRuns: string[] = [];
 
+  @property()
+  tabState?: ChecksTabState;
+
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
   constructor() {
@@ -315,6 +300,23 @@
     ];
   }
 
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState') && this.tabState) {
+      const {statusOrCategory} = this.tabState;
+      if (
+        statusOrCategory === RunStatus.RUNNING ||
+        statusOrCategory === RunStatus.RUNNABLE
+      ) {
+        this.updateComplete.then(() => {
+          const s = statusOrCategory.toString().toLowerCase();
+          const el = this.shadowRoot?.querySelector(`.${s} .sectionHeader`);
+          el?.scrollIntoView({block: 'center'});
+        });
+      }
+    }
+  }
+
   render() {
     return html`
       <h2 class="heading-2">Runs</h2>
@@ -322,6 +324,7 @@
         id="filterInput"
         type="text"
         placeholder="Filter runs by regular expression"
+        ?hidden="${!this.showFilter()}"
         @input="${this.onInput}"
       />
       ${this.renderSection(RunStatus.COMPLETED)}
@@ -384,7 +387,7 @@
     if (runs.length === 0) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
     const expandedClass = expanded ? 'expanded' : 'collapsed';
-    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+    const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
     return html`
       <div class="${status.toLowerCase()} ${expandedClass}">
         <div
@@ -416,6 +419,14 @@
       .deselected="${deselected}"
     ></gr-checks-run>`;
   }
+
+  showFilter(): boolean {
+    const show = this.runs.length > 10;
+    if (!show && this.filterRegExp.source.length > 0) {
+      this.filterRegExp = new RegExp('');
+    }
+    return show;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index ad4f2ae..f7f7054 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -28,19 +28,29 @@
   allActions$,
   allResults$,
   allRuns$,
+  checksPatchsetNumber$,
+  someProvidersAreLoading$,
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
 import {sharedStyles} from '../../styles/shared-styles';
-import {changeNum$, currentPatchNum$} from '../../services/change/change-model';
-import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
+import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {
   ActionTriggeredEvent,
   fireActionTriggered,
 } from '../../services/checks/checks-util';
-import {checkRequiredProperty} from '../../utils/common-util';
-import {RunSelectedEvent} from './gr-checks-runs';
+import {
+  assertIsDefined,
+  check,
+  checkRequiredProperty,
+} from '../../utils/common-util';
+import {RunSelectedEvent} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
+import {fireAlert} from '../../utils/event-util';
+import {appContext} from '../../services/app-context';
+import {from, timer} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -59,21 +69,31 @@
   tabState?: ChecksTabState;
 
   @property()
-  currentPatchNum: PatchSetNum | undefined = undefined;
+  checksPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   @property()
   changeNum: NumericChangeId | undefined = undefined;
 
+  @property()
+  someProvidersAreLoading = false;
+
   @internalProperty()
   selectedRuns: string[] = [];
 
+  private readonly checksService = appContext.checksService;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
     this.subscribe('actions', allActions$);
     this.subscribe('results', allResults$);
-    this.subscribe('currentPatchNum', currentPatchNum$);
+    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
     this.subscribe('changeNum', changeNum$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
       this.handleActionTriggered(e.detail.action, e.detail.run)
@@ -113,7 +133,6 @@
   }
 
   render() {
-    const ps = `Patchset ${this.currentPatchNum} (Latest)`;
     const filteredRuns = this.runs.filter(
       r =>
         this.selectedRuns.length === 0 ||
@@ -123,14 +142,11 @@
       <div class="header">
         <div class="left">
           <gr-dropdown-list
-            value="${ps}"
-            .items="${[
-              {
-                value: `${ps}`,
-                text: `${ps}`,
-              },
-            ]}"
+            value="${this.checksPatchsetNumber}"
+            .items="${this.createPatchsetDropdownItems()}"
+            @value-change="${this.onPatchsetSelected}"
           ></gr-dropdown-list>
+          <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
         </div>
         <div class="right">
           ${this.actions.map(this.renderAction)}
@@ -141,22 +157,44 @@
           class="runs"
           .runs="${this.runs}"
           .selectedRuns="${this.selectedRuns}"
+          .tabState="${this.tabState}"
           @run-selected="${this.handleRunSelected}"
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
+          .tabState="${this.tabState}"
           .runs="${filteredRuns}"
+          .selectedRunsCount="${this.selectedRuns.length}"
+          @run-selected="${this.handleRunSelected}"
         ></gr-checks-results>
       </div>
     `;
   }
 
+  private onPatchsetSelected(e: CustomEvent<{value: string}>) {
+    const patchset = Number(e.detail.value);
+    check(!isNaN(patchset), 'selected patchset must be a number');
+    this.checksService.setPatchset(patchset as PatchSetNumber);
+  }
+
+  private createPatchsetDropdownItems() {
+    if (!this.latestPatchsetNumber) return [];
+    return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
+      assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
+      const index = this.latestPatchsetNumber - i;
+      const postfix = index === this.latestPatchsetNumber ? ' (latest)' : '';
+      return {
+        value: `${index}`,
+        text: `Patchset ${index}${postfix}`,
+      };
+    });
+  }
+
   protected updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
     if (changedProperties.has('tabState')) {
-      const check = this.tabState?.checkName;
-      if (check) {
-        this.selectedRuns = [check];
+      if (this.tabState) {
+        this.selectedRuns = [];
       }
     }
   }
@@ -169,22 +207,44 @@
 
   handleActionTriggered(action: Action, run?: CheckRun) {
     if (!this.changeNum) return;
-    if (!this.currentPatchNum) return;
-    // TODO(brohlfs): The callback is supposed to be returning a promise.
-    // A toast should be displayed until the promise completes. And then the
-    // data should be updated.
-    action.callback(
+    if (!this.checksPatchsetNumber) return;
+    const promise = action.callback(
       this.changeNum,
-      this.currentPatchNum as number,
+      this.checksPatchsetNumber,
       run?.attempt,
       run?.externalId,
       run?.checkName,
       action.name
     );
+    // Plugins *should* return a promise, but you never know ...
+    if (promise?.then) {
+      const prefix = `Triggering action '${action.name}'`;
+      fireAlert(this, `${prefix} ...`);
+      from(promise)
+        // If the action takes longer than 5 seconds, then most likely the
+        // user is either not interested or the result not relevant anymore.
+        .pipe(takeUntil(timer(5000)))
+        .subscribe(result => {
+          if (result.errorMessage) {
+            fireAlert(this, `${prefix} failed with ${result.errorMessage}.`);
+          } else {
+            fireAlert(this, `${prefix} successful.`);
+            this.checksService.reloadForCheck(run?.checkName);
+          }
+        });
+    } else {
+      fireAlert(this, `Action '${action.name}' triggered.`);
+    }
   }
 
   handleRunSelected(e: RunSelectedEvent) {
-    this.toggleSelected(e.detail.checkName);
+    if (e.detail.reset) {
+      this.selectedRuns = [];
+      return;
+    }
+    if (e.detail.checkName) {
+      this.toggleSelected(e.detail.checkName);
+    }
   }
 
   toggleSelected(checkName: string) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
new file mode 100644
index 0000000..4d8d8cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -0,0 +1,49 @@
+/**
+ * @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.
+ */
+
+export interface RunSelectedEventDetail {
+  reset: boolean;
+  checkName?: string;
+}
+
+export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'run-selected': RunSelectedEvent;
+  }
+}
+
+export function fireRunSelected(target: EventTarget, checkName: string) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {reset: false, checkName},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireRunSelectionReset(target: EventTarget) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {reset: true},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 0361b9a..c0f7320 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../../styles/shared-styles';
 import '../../shared/gr-avatar/gr-avatar';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-dropdown_html';
@@ -37,9 +36,7 @@
 }
 
 @customElement('gr-account-dropdown')
-export class GrAccountDropdown extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountDropdown extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -68,8 +65,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._handleLocationChange();
     this.listen(window, 'location-change', '_handleLocationChange');
     this.restApiService.getConfig().then(cfg => {
@@ -85,9 +82,9 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.unlisten(window, 'location-change', '_handleLocationChange');
+    super.disconnectedCallback();
   }
 
   _getLinks(switchAccountUrl: string, path: string) {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
index dc5d1b4..d2d27b7 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-dropdown.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-dropdown');
 
@@ -25,7 +24,6 @@
   let element;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index b28b13e..181e132 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-error-dialog_html';
@@ -29,9 +28,7 @@
 }
 
 @customElement('gr-error-dialog')
-export class GrErrorDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrErrorDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
similarity index 63%
rename from polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
rename to polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index ea8f7c5..fa57a23 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -15,21 +15,25 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-error-dialog.js';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrErrorDialog} from './gr-error-dialog';
 
 const basicFixture = fixtureFromElement('gr-error-dialog');
 
 suite('gr-error-dialog tests', () => {
-  let element;
+  let element: GrErrorDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('dismiss tap fires event', done => {
-    element.addEventListener('dismiss', () => { done(); });
-    MockInteractions.tap(element.$.dialog.$.confirm);
+    element.addEventListener('dismiss', () => done());
+    MockInteractions.tap(
+      (queryAndAssert(element, '#dialog') as GrDialog).$.confirm
+    );
   });
 });
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 4a86005..b02dfa2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -19,7 +19,6 @@
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-error-manager_html';
@@ -77,9 +76,7 @@
 const DEBOUNCER_CHECK_LOGGED_IN = 'checkLoggedIn';
 
 @customElement('gr-error-manager')
-export class GrErrorManager extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrErrorManager extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -120,8 +117,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.listen(document, EventType.SERVER_ERROR, '_handleServerError');
     this.listen(document, EventType.NETWORK_ERROR, '_handleNetworkError');
     this.listen(document, EventType.SHOW_ALERT, '_handleShowAlert');
@@ -141,8 +138,7 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this._clearHideAlertHandle();
     this.unlisten(document, EventType.SERVER_ERROR, '_handleServerError');
     this.unlisten(document, EventType.NETWORK_ERROR, '_handleNetworkError');
@@ -156,6 +152,7 @@
     if (this._authErrorHandlerDeregistrationHook) {
       this._authErrorHandlerDeregistrationHook();
     }
+    super.disconnectedCallback();
   }
 
   _shouldSuppressError(msg: string) {
@@ -324,7 +321,7 @@
       // Persist alert until navigation.
       this.listen(document, 'location-change', '_hideAlert');
     } else {
-      this._hideAlertHandle = this.async(
+      this._hideAlertHandle = window.setTimeout(
         this._hideAlert,
         HIDE_ALERT_TIMEOUT_MS
       );
@@ -350,7 +347,7 @@
 
   _clearHideAlertHandle() {
     if (this._hideAlertHandle !== null) {
-      this.cancelAsync(this._hideAlertHandle);
+      window.clearTimeout(this._hideAlertHandle);
       this._hideAlertHandle = null;
     }
   }
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 4bd90ea..4f37fad 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
@@ -42,7 +41,7 @@
 
 @customElement('gr-keyboard-shortcuts-dialog')
 export class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -76,19 +75,19 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.addKeyboardShortcutDirectoryListener(
       this.keyboardShortcutDirectoryListener
     );
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.removeKeyboardShortcutDirectoryListener(
       this.keyboardShortcutDirectoryListener
     );
+    super.disconnectedCallback();
   }
 
   _handleCloseTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index bed07a6..9c088c2 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-icons/gr-icons';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-main-header_html';
@@ -101,9 +100,7 @@
 ]);
 
 @customElement('gr-main-header')
-export class GrMainHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMainHeader extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -165,15 +162,15 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadAccount();
     this._loadConfig();
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    super.disconnectedCallback();
   }
 
   reload() {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index d9c43d6..f764373 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -478,7 +478,7 @@
 
   test('shows feedback icon when URL provided', async () => {
     assert.isEmpty(element._feedbackURL);
-    assert.isNull(query(element, '.feedbackButton'));
+    assert.isNotOk(query(element, '.feedbackButton'));
 
     const url = 'report_bug_url';
     const config: ServerInfo = {
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 632ce4c..fa81103 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {
+  BasePatchSetNum,
   BranchName,
   ChangeConfigInfo,
   ChangeInfo,
@@ -253,7 +254,7 @@
   changeNum: NumericChangeId;
   project: RepoName;
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   edit?: boolean;
   host?: string;
   messageHash?: string;
@@ -310,7 +311,7 @@
   project: RepoName;
   path?: string;
   patchNum?: PatchSetNum | null;
-  basePatchNum?: PatchSetNum | null;
+  basePatchNum?: BasePatchSetNum | null;
   lineNum?: number | string;
   leftSide?: boolean;
   commentId?: UrlEncodedCommentId;
@@ -435,7 +436,7 @@
 
   mapCommentlinks: uninitializedMapCommentLinks,
 
-  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: PatchSetNum) {
+  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum) {
     if (basePatchNum && !patchNum) {
       throw new Error('Cannot use base patch number without patch number.');
     }
@@ -591,7 +592,7 @@
   getUrlForChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
     messageHash?: string
   ) {
@@ -635,7 +636,7 @@
   navigateToChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
     redirect?: boolean
   ) {
@@ -652,7 +653,7 @@
     change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number
   ) {
     return this.getUrlForDiffById(
@@ -686,7 +687,7 @@
     project: RepoName,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number,
     leftSide?: boolean
   ) {
@@ -751,7 +752,7 @@
     change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number
   ) {
     this._navigate(
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 676ef7b..7804970 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
@@ -23,7 +22,6 @@
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
 import {htmlTemplate} from './gr-router_html';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {
   DashboardSection,
   GeneratedWebLink,
@@ -50,6 +48,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
+  BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
@@ -68,6 +67,14 @@
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
+import {
+  encodeURL,
+  getBaseUrl,
+  toPath,
+  toPathname,
+  toSearchParams,
+} from '../../../utils/url-util';
+import {Execution} from '../../../constants/reporting';
 
 const RoutePattern = {
   ROOT: '/',
@@ -282,13 +289,11 @@
 
 interface PatchRangeParams {
   patchNum?: PatchSetNum | null;
-  basePatchNum?: PatchSetNum | null;
+  basePatchNum?: BasePatchSetNum | null;
 }
 
 @customElement('gr-router')
-export class GrRouter extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRouter extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -762,20 +767,23 @@
 
   /**  Page.js middleware that try parse the querystring into queryMap. */
   _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
-    let queryMap: Map<string, string> | URLSearchParams = new Map<
-      string,
-      string
-    >();
+    (ctx as PageContextWithQueryMap).queryMap = this.createQueryMap(ctx);
+    next();
+  }
+
+  private createQueryMap(ctx: PageContext) {
     if (ctx.querystring) {
       // https://caniuse.com/#search=URLSearchParams
       if (window.URLSearchParams) {
-        queryMap = new URLSearchParams(ctx.querystring);
+        return new URLSearchParams(ctx.querystring);
       } else {
-        queryMap = new Map(this._parseQueryString(ctx.querystring));
+        this.reporting.reportExecution(Execution.REACHABLE_CODE, {
+          id: 'noURLSearchParams',
+        });
+        return new Map(this._parseQueryString(ctx.querystring));
       }
     }
-    (ctx as PageContextWithQueryMap).queryMap = queryMap;
-    next();
+    return new Map<string, string>();
   }
 
   /**
@@ -806,13 +814,13 @@
       pattern,
       (ctx, next) => this._loadUserMiddleware(ctx, next),
       (ctx, next) => this._queryStringMiddleware(ctx, next),
-      data => {
+      ctx => {
         this.reporting.locationChanged(handlerName);
         const promise = authRedirect
-          ? this._redirectIfNotLoggedIn(data)
+          ? this._redirectIfNotLoggedIn(ctx)
           : Promise.resolve();
         promise.then(() => {
-          this[handlerName](data as PageContextWithQueryMap);
+          this[handlerName](ctx as PageContextWithQueryMap);
         });
       }
     );
@@ -846,6 +854,21 @@
       next();
     });
 
+    // Remove the tracking param 'usp' (User Source Parameter) from the URL,
+    // just to have users look at cleaner URLs.
+    page((ctx, next) => {
+      if (window.URLSearchParams) {
+        const pathname = toPathname(ctx.canonicalPath);
+        const searchParams = toSearchParams(ctx.canonicalPath);
+        if (searchParams.has('usp')) {
+          searchParams.delete('usp');
+          this._redirect(toPath(pathname, searchParams));
+          return;
+        }
+      }
+      next();
+    });
+
     // Middleware
     page((ctx, next) => {
       document.body.scrollTop = 0;
@@ -860,7 +883,7 @@
 
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
-      this.async(() => {
+      setTimeout(() => {
         const detail: LocationChangeEventDetail = {
           hash: window.location.hash,
           pathname: window.location.pathname,
@@ -1545,7 +1568,7 @@
     const params: GenerateUrlChangeViewParameters = {
       project: ctx.params[0] as RepoName,
       changeNum,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
       queryMap: ctx.queryMap,
@@ -1576,7 +1599,7 @@
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
       changeNum,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       path: ctx.params[8],
       view: GerritView.DIFF,
@@ -1595,7 +1618,7 @@
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyChangeViewParameters = {
       changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[3]),
+      basePatchNum: convertToPatchSetNum(ctx.params[3]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[5]),
       view: GerritView.CHANGE,
       querystring: ctx.querystring,
@@ -1612,7 +1635,7 @@
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyDiffViewParameters = {
       changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[2]),
+      basePatchNum: convertToPatchSetNum(ctx.params[2]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[4]),
       path: ctx.params[5],
       view: GerritView.DIFF,
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 f7fe091..ad4ebcb 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
@@ -19,10 +19,8 @@
 import './gr-router.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -804,7 +802,6 @@
       });
 
       test('redirects to dashboard if logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(true));
         const data = {
           canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
@@ -934,7 +931,6 @@
       });
 
       test('dashboard while signed in sets params', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(true));
         const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isFalse(redirectToLoginStub.called);
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 97d5271..504a9e5 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-search-bar_html';
@@ -40,10 +39,13 @@
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
   'added:',
+  'after:',
   'age:',
   'age:1week', // Give an example age
   'assignee:',
+  'attention:',
   'author:',
+  'before:',
   'branch:',
   'bug:',
   'cc:',
@@ -77,6 +79,7 @@
   'is:assigned',
   'is:closed',
   'is:ignored',
+  'is:merge',
   'is:merged',
   'is:open',
   'is:owner',
@@ -88,6 +91,8 @@
   'is:watched',
   'is:wip',
   'label:',
+  'mergedafter:',
+  'mergedbefore:',
   'message:',
   'onlyexts:',
   'onlyextensions:',
@@ -142,7 +147,7 @@
 
 @customElement('gr-search-bar')
 export class GrSearchBar extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -193,8 +198,8 @@
     this.query = (input: string) => this._getSearchSuggestions(input);
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
       const mergeability =
         serverConfig &&
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
index e553402..ae7e10c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -18,10 +18,9 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-search-bar.js';
 import '../../../scripts/util.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-search-bar');
 
@@ -126,7 +125,6 @@
   suite('_getSearchSuggestions', () => {
     setup(() => {
       // Ensure that config.change.mergeability_computation_behavior is not set.
-      stubRestApi('getConfig').returns(Promise.resolve({}));
       element = basicFixture.instantiate();
     });
 
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index b698732..02036b4 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../gr-search-bar/gr-search-bar';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-smart-search_html';
@@ -35,9 +34,7 @@
 const ME_EXPRESSION = 'me';
 
 @customElement('gr-smart-search')
-export class GrSmartSearch extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSmartSearch extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -66,8 +63,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService.getConfig().then(cfg => {
       this._config = cfg;
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index e381213..5111337 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-diff/gr-diff';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-apply-fix-dialog_html';
@@ -32,6 +31,7 @@
   FixSuggestionInfo,
   PatchSetNum,
   RobotId,
+  BasePatchSetNum,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -53,9 +53,7 @@
 }
 
 @customElement('gr-apply-fix-dialog')
-export class GrApplyFixDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrApplyFixDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -134,8 +132,8 @@
     });
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.refitOverlay = () => {
       // re-center the dialog as content changed
       fireEvent(this.$.applyFixOverlay, 'iron-resize');
@@ -143,11 +141,11 @@
     this.addEventListener('diff-context-expanded', this.refitOverlay);
   }
 
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this.refitOverlay) {
       this.removeEventListener('diff-context-expanded', this.refitOverlay);
     }
+    super.disconnectedCallback();
   }
 
   _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
@@ -280,7 +278,11 @@
       .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
       .then(res => {
         if (res && res.ok) {
-          GerritNav.navigateToChange(change, EditPatchSetNum, patchNum);
+          GerritNav.navigateToChange(
+            change,
+            EditPatchSetNum,
+            patchNum as BasePatchSetNum
+          );
           this._close();
         }
         this._isApplyFixLoading = false;
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 b60a585..0ca6ea3 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
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
@@ -51,7 +50,6 @@
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side} from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {pluralize} from '../../../utils/string-util';
 
 export type CommentIdToCommentThreadMap = {
@@ -597,9 +595,7 @@
 export const _testOnly_getCommentsForPath =
   ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
-export class GrCommentApi extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCommentApi extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -609,10 +605,6 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly flagsService = appContext.flagsService;
-
-  private isPortingCommentsExperimentEnabled = false;
-
   /** @override */
   created() {
     super.created();
@@ -620,9 +612,6 @@
 
   constructor() {
     super();
-    this.isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.PORTING_COMMENTS
-    );
   }
 
   /**
@@ -636,12 +625,8 @@
       this.restApiService.getDiffComments(changeNum),
       this.restApiService.getDiffRobotComments(changeNum),
       this.restApiService.getDiffDrafts(changeNum),
-      this.isPortingCommentsExperimentEnabled
-        ? this.restApiService.getPortedComments(changeNum, revision)
-        : Promise.resolve({}),
-      this.isPortingCommentsExperimentEnabled
-        ? this.restApiService.getPortedDrafts(changeNum, revision)
-        : Promise.resolve({}),
+      this.restApiService.getPortedComments(changeNum, revision),
+      this.restApiService.getPortedDrafts(changeNum, revision),
     ];
 
     return Promise.all(commentsPromise).then(
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 94ec78f..fbbe1fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -60,7 +60,6 @@
   test('loads logged-in', () => {
     const changeNum = 1234;
 
-    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     const getCommentsStub = stubRestApi('getDiffComments').returns(
         Promise.resolve({
           'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
index 6f9705f..388cc73 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-coverage-layer_html';
@@ -35,8 +34,7 @@
 ]);
 
 @customElement('gr-coverage-layer')
-export class GrCoverageLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+export class GrCoverageLayer extends LegacyElementMixin(PolymerElement)
   implements DiffLayer {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index de68554..4c10d5c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-hovercard/gr-hovercard';
 import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import './gr-diff-builder-side-by-side';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-builder-element_html';
@@ -43,7 +42,7 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode} from '../../../api/diff';
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
@@ -67,9 +66,7 @@
 }
 
 @customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffBuilderElement extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -164,11 +161,11 @@
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this._builder) {
       this._builder.clear();
     }
+    super.disconnectedCallback();
   }
 
   get diffElement() {
@@ -183,7 +180,11 @@
     return coverageRanges.filter(range => range && range.side === 'right');
   }
 
-  render(keyLocations: KeyLocations, prefs: DiffPreferencesInfo) {
+  render(
+    keyLocations: KeyLocations,
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ) {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
@@ -202,7 +203,7 @@
     if (!this.diff) {
       throw Error('Cannot render a diff without DiffInfo.');
     }
-    this._builder = this._getDiffBuilder(this.diff, prefs);
+    this._builder = this._getDiffBuilder(this.diff, prefs, renderPrefs);
 
     this.$.processor.context = prefs.context;
     this.$.processor.keyLocations = keyLocations;
@@ -325,7 +326,7 @@
       sectionEl.parentNode.removeChild(sectionEl);
     }
 
-    this.async(() => fireEvent(this, 'render-content'), 1);
+    setTimeout(() => fireEvent(this, 'render-content'), 1);
   }
 
   cancel() {
@@ -344,7 +345,11 @@
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(diff: DiffInfo, prefs: DiffPreferencesInfo): GrDiffBuilder {
+  _getDiffBuilder(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ): GrDiffBuilder {
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       this._handlePreferenceError('tab size');
     }
@@ -367,7 +372,8 @@
         localPrefs,
         this.diffElement,
         this.baseImage,
-        this.revisionImage
+        this.revisionImage,
+        renderPrefs
       );
     } else if (diff.binary) {
       // If the diff is binary, but not an image.
@@ -377,14 +383,16 @@
         diff,
         localPrefs,
         this.diffElement,
-        this._layers
+        this._layers,
+        renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
         diff,
         localPrefs,
         this.diffElement,
-        this._layers
+        this._layers,
+        renderPrefs
       );
     }
     if (!builder) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
index 5b3f225..fb37349 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -19,6 +19,7 @@
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {RenderPreferences} from '../../../api/diff';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
@@ -30,9 +31,10 @@
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
     private readonly _baseImage: ImageInfo | null,
-    private readonly _revisionImage: ImageInfo | null
+    private readonly _revisionImage: ImageInfo | null,
+    renderPrefs?: RenderPreferences
   ) {
-    super(diff, prefs, outputEl, []);
+    super(diff, prefs, outputEl, [], renderPrefs);
   }
 
   public renderDiff() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 2025732..51b8135 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -21,15 +21,17 @@
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderSideBySide extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = []
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
   ) {
-    super(diff, prefs, outputEl, layers);
+    super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
   _getMoveControlsConfig() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 1bf3d69..e927fdf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -20,15 +20,17 @@
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderUnified extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = []
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
   ) {
-    super(diff, prefs, outputEl, layers);
+    super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
   _getMoveControlsConfig() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 4943298..37142ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -17,6 +17,7 @@
 import {
   ContentLoadNeededEventDetail,
   MovedLinkClickedEventDetail,
+  RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
@@ -76,6 +77,8 @@
 
   private readonly _prefs: DiffPreferencesInfo;
 
+  private readonly _renderPrefs?: RenderPreferences;
+
   protected readonly _outputEl: HTMLElement;
 
   readonly groups: GrDiffGroup[];
@@ -92,7 +95,8 @@
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = []
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
   ) {
     this._diff = diff;
     this._numLinesLeft = this._diff.content
@@ -102,6 +106,7 @@
         }, 0)
       : 0;
     this._prefs = prefs;
+    this._renderPrefs = renderPrefs;
     this._outputEl = outputEl;
     this.groups = [];
     this.blameInfo = null;
@@ -542,7 +547,9 @@
       td.dataset['value'] = number.toString();
 
       if (
-        (this._prefs.show_file_comment_button === false && number === 'FILE') ||
+        ((this._prefs.show_file_comment_button === false ||
+          this._renderPrefs?.show_file_comment_button === false) &&
+          number === 'FILE') ||
         number === 'LOST'
       ) {
         return td;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index cc3be07..0ba794f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -25,7 +25,6 @@
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-cursor_html';
@@ -54,9 +53,7 @@
 }
 
 @customElement('gr-diff-cursor')
-export class GrDiffCursor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffCursor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -131,8 +128,9 @@
 
   /** @override */
   disconnectedCallback() {
-    super.disconnectedCallback();
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
+    this.$.cursorManager.unsetCursor();
+    super.disconnectedCallback();
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 094f2d7..fb28e2b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -17,7 +17,6 @@
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-highlight_html';
@@ -57,9 +56,7 @@
 const DEBOUNCER_SELECTION_CHANGE = 'selectionChange';
 
 @customElement('gr-diff-highlight')
-export class GrDiffHighlight extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffHighlight extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -91,8 +88,9 @@
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_SELECTION_CHANGE);
+    super.disconnectedCallback();
   }
 
   get diffBuilder() {
@@ -171,11 +169,11 @@
         rangeNodes.forEach(rangeNode => {
           rangeNode.classList.add('rangeHoverHighlight');
         });
-        const chipNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+        const hintNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
         );
-        if (chipNode) {
-          chipNode.shadowRoot
+        if (hintNode) {
+          hintNode.shadowRoot
             ?.querySelectorAll('.rangeHighlight')
             .forEach(highlightNode =>
               highlightNode.classList.add('rangeHoverHighlight')
@@ -188,11 +186,11 @@
         rangeNodes.forEach(rangeNode => {
           rangeNode.classList.remove('rangeHoverHighlight');
         });
-        const chipNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+        const hintNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
         );
-        if (chipNode) {
-          chipNode.shadowRoot
+        if (hintNode) {
+          hintNode.shadowRoot
             ?.querySelectorAll('.rangeHoverHighlight')
             .forEach(highlightNode =>
               highlightNode.classList.remove('rangeHoverHighlight')
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 47b4a1f..d40a8a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -16,8 +16,6 @@
  */
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../gr-diff/gr-diff';
-import '../gr-syntax-layer/gr-syntax-layer';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
@@ -77,6 +75,7 @@
   fireAlert,
   fireServerError,
   fireEvent,
+  waitForEventOnce,
 } from '../../../utils/event-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -119,7 +118,6 @@
 
 export interface GrDiffHost {
   $: {
-    syntaxLayer: GrSyntaxLayer & Element;
     diff: GrDiff;
   };
 }
@@ -132,9 +130,7 @@
  * specific component, while <gr-diff> is a re-usable component.
  */
 @customElement('gr-diff-host')
-export class GrDiffHost extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffHost extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -237,7 +233,7 @@
   @property({type: Object})
   _revisionImage: Base64ImageFile | null = null;
 
-  @property({type: Object, notify: true})
+  @property({type: Object, notify: true, observer: 'diffChanged'})
   diff?: DiffInfo;
 
   @property({type: Object})
@@ -258,6 +254,7 @@
   @property({
     type: Boolean,
     computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+    observer: '_syntaxHighlightingEnabledChanged',
   })
   _syntaxHighlightingEnabled?: boolean;
 
@@ -270,6 +267,8 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly syntaxLayer = new GrSyntaxLayer();
+
   /** @override */
   created() {
     super.created();
@@ -308,17 +307,17 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.clear();
+    super.disconnectedCallback();
   }
 
   initLayers() {
@@ -336,6 +335,10 @@
       });
   }
 
+  diffChanged(diff?: DiffInfo) {
+    this.syntaxLayer.init(diff);
+  }
+
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
@@ -374,7 +377,7 @@
 
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
-      const event = await this._onRenderOnce();
+      const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
       if (shouldReportMetric) {
         // We report diffViewContentDisplayed only on reload caused
         // by params changed - expected only on Diff Page.
@@ -384,7 +387,7 @@
       if (needsSyntaxHighlighting) {
         this.reporting.time(TimingLabel.SYNTAX);
         try {
-          await this.$.syntaxLayer.process();
+          await this.syntaxLayer.process();
         } finally {
           this.reporting.timeEnd(TimingLabel.SYNTAX);
         }
@@ -402,17 +405,7 @@
 
   private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
     // Get layers from plugins (if any).
-    return [this.$.syntaxLayer, ...this.jsAPI.getDiffLayers(path, changeNum)];
-  }
-
-  private _onRenderOnce(): Promise<CustomEvent> {
-    return new Promise<CustomEvent>(resolve => {
-      const callback = (event: CustomEvent) => {
-        this.removeEventListener('render', callback);
-        resolve(event);
-      };
-      this.addEventListener('render', callback);
-    });
+    return [this.syntaxLayer, ...this.jsAPI.getDiffLayers(path, changeNum)];
   }
 
   clear() {
@@ -510,7 +503,7 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
-    this.$.syntaxLayer.cancel();
+    this.syntaxLayer.cancel();
   }
 
   getCursorStops() {
@@ -1001,6 +994,10 @@
     fireEvent(this, 'diff-comments-modified');
   }
 
+  _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
+    this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
+  }
+
   _isSyntaxHighlightingEnabled(
     preferenceChangeRecord?: PolymerDeepPropertyChange<
       DiffPreferencesInfo,
@@ -1048,11 +1045,11 @@
     const renderUpdateListener: DiffLayerListener = start => {
       if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
         this.reporting.diffViewDisplayed();
-        this.$.syntaxLayer.removeListener(renderUpdateListener);
+        this.syntaxLayer.removeListener(renderUpdateListener);
       }
     };
 
-    this.$.syntaxLayer.addListener(renderUpdateListener);
+    this.syntaxLayer.addListener(renderUpdateListener);
   }
 
   _handleRenderStart() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index 84a2e4a..e82489c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -42,9 +42,4 @@
     show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
   >
   </gr-diff>
-  <gr-syntax-layer
-    id="syntaxLayer"
-    enabled="[[_syntaxHighlightingEnabled]]"
-    diff="[[diff]]"
-  ></gr-syntax-layer>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 01c78f0..0bb7e7e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -21,11 +21,10 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
-import {Side} from '../../../constants/constants.js';
+import {Side, createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {createChange} from '../../../test/test-data-generators.js';
 import {CoverageType} from '../../../types/types.js';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
@@ -130,7 +129,7 @@
     test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
-      sinon.stub(element.$.syntaxLayer, 'process').returns(
+      sinon.stub(element.syntaxLayer, 'process').returns(
           new Promise(resolve => {
             notifySyntaxProcessed = resolve;
           })
@@ -170,7 +169,7 @@
 
     test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
-      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
           resolve => {
             notifySyntaxProcessed = resolve;
           }));
@@ -970,7 +969,7 @@
   suite('create-comment', () => {
     setup(async () => {
       loggedIn = true;
-      element.attached();
+      element.connectedCallback();
       await flush();
     });
 
@@ -1292,7 +1291,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1301,7 +1300,7 @@
           a: ['foo'],
         }],
       };
-      assert.isTrue(element.$.syntaxLayer.enabled);
+      assert.isTrue(element.syntaxLayer.enabled);
     });
 
     test('rendering large diff disables syntax', () => {
@@ -1311,17 +1310,17 @@
           a: [new Array(501).join('*')],
         }],
       };
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
 
     test('starts syntax layer processing on render event', async () => {
-      sinon.stub(element.$.syntaxLayer, 'process')
+      sinon.stub(element.syntaxLayer, 'process')
           .returns(Promise.resolve());
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
       element.dispatchEvent(
           new CustomEvent('render', {bubbles: true, composed: true}));
-      assert.isTrue(element.$.syntaxLayer.process.called);
+      assert.isTrue(element.syntaxLayer.process.called);
     });
   });
 
@@ -1346,11 +1345,11 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
 
     test('still disabled for large diff', () => {
@@ -1360,7 +1359,7 @@
           a: [new Array(501).join('*')],
         }],
       };
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 60f2853..cc7bb98 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import {DiffViewMode} from '../../../constants/constants';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-mode-selector_html';
@@ -29,9 +28,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-diff-mode-selector')
-export class GrDiffModeSelector extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffModeSelector extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -48,7 +45,9 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  attached() {
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
     ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 8829afc..96c2c03 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences-dialog_html';
@@ -37,8 +36,8 @@
   };
 }
 @customElement('gr-diff-preferences-dialog')
-export class GrDiffPreferencesDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrDiffPreferencesDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index 9fafcfa..1f0c246 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
@@ -92,9 +91,7 @@
  *    the rest is not.
  */
 @customElement('gr-diff-processor')
-export class GrDiffProcessor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffProcessor extends LegacyElementMixin(PolymerElement) {
   @property({type: Number})
   context = 3;
 
@@ -117,17 +114,17 @@
   _isScrolling?: boolean;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.listen(window, 'scroll', '_handleWindowScroll');
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_RESET_IS_SCROLLING);
     this.cancel();
     this.unlisten(window, 'scroll', '_handleWindowScroll');
+    super.disconnectedCallback();
   }
 
   _handleWindowScroll() {
@@ -176,7 +173,7 @@
         let currentBatch = 0;
         const nextStep = () => {
           if (this._isScrolling) {
-            this._nextStepHandle = this.async(nextStep, 100);
+            this._nextStepHandle = window.setTimeout(nextStep, 100);
             return;
           }
           // If we are done, resolve the promise.
@@ -199,7 +196,7 @@
           state.chunkIndex = stateUpdate.newChunkIndex;
           if (currentBatch >= this._asyncThreshold) {
             currentBatch = 0;
-            this._nextStepHandle = this.async(nextStep, 1);
+            this._nextStepHandle = window.setTimeout(nextStep, 1);
           } else {
             nextStep.call(this);
           }
@@ -218,7 +215,7 @@
    */
   cancel() {
     if (this._nextStepHandle !== null) {
-      this.cancelAsync(this._nextStepHandle);
+      window.clearTimeout(this._nextStepHandle);
       this._nextStepHandle = null;
     }
     if (this._processPromise) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index b8f7498..5ecc962 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -1113,7 +1113,7 @@
   test('detaching cancels', () => {
     element = basicFixture.instantiate();
     sinon.stub(element, 'cancel');
-    element.detached();
+    element.disconnectedCallback();
     assert(element.cancel.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index 9493478..cee5ef6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -17,7 +17,6 @@
 import '../../../styles/shared-styles';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-selection_html';
@@ -53,9 +52,7 @@
 }
 
 @customElement('gr-diff-selection')
-export class GrDiffSelection extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffSelection extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -77,8 +74,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.classList.add(SelectionClass.RIGHT);
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 2c4b8f6..ba36c67 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -31,7 +31,6 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-view_html';
@@ -64,6 +63,7 @@
 import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {
+  BasePatchSetNum,
   ChangeInfo,
   CommitId,
   ConfigInfo,
@@ -95,7 +95,7 @@
 } from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
-import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
@@ -127,7 +127,7 @@
 
 @customElement('gr-diff-view')
 export class GrDiffView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -326,11 +326,6 @@
     this._throttledToggleFileReviewed = this._throttleWrap(e =>
       this._handleToggleFileReviewed(e as CustomKeyboardEvent)
     );
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
@@ -344,10 +339,11 @@
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
+    super.disconnectedCallback();
   }
 
   _getLoggedIn() {
@@ -645,14 +641,20 @@
     }
   }
 
+  // Similar to gr-change-view._handleOpenReplyDialog
   _handleOpenReplyDialog(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     if (this.modifierPressed(e)) return;
-    if (!this._loggedIn) return;
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        fireEvent(this, 'show-auth-required');
+        return;
+      }
 
-    this.set('changeViewState.showReplyDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
+      this.set('changeViewState.showReplyDialog', true);
+      e.preventDefault();
+      this._navToChangeView();
+    });
   }
 
   _handleToggleLeftPane(e: CustomKeyboardEvent) {
@@ -1661,7 +1663,7 @@
       this._change,
       this._path,
       this._patchRange.basePatchNum,
-      'PARENT' as PatchSetNum,
+      'PARENT' as BasePatchSetNum,
       this.params?.view === GerritView.DIFF && this.params?.commentLink
         ? this._focusLineNum
         : undefined
@@ -1703,7 +1705,7 @@
       this._change,
       this._path,
       latestPatchNum,
-      this._patchRange.patchNum
+      this._patchRange.patchNum as BasePatchSetNum
     );
   }
 
@@ -1755,13 +1757,10 @@
     return disableDiffPrefs || !loggedIn;
   }
 
-  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _navigateToNextUnreviewedFile() {
     if (!this._path) return;
     if (!this._fileList) return;
     if (!this._reviewedFiles) return;
-
-    this._setReviewed(true);
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
     const unreviewedFiles = this._fileList.filter(
@@ -1770,6 +1769,12 @@
     this._navToFile(this._path, unreviewedFiles, 1);
   }
 
+  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    this._setReviewed(true);
+    this._navigateToNextUnreviewedFile();
+  }
+
   _navigateToNextFileWithCommentThread() {
     if (!this._path) return;
     if (!this._fileList) return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index bfb0cd8..651e95b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -427,7 +427,7 @@
   </gr-diff-preferences-dialog>
   <gr-diff-cursor
     id="cursor"
-    on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
+    on-navigate-to-next-unreviewed-file="_navigateToNextUnreviewedFile"
     on-navigate-to-next-file-with-comments="_navigateToNextFileWithCommentThread"
   ></gr-diff-cursor>
   <gr-comment-api id="commentAPI"></gr-comment-api>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index b5473a3..5c3dfdc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,7 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
@@ -28,7 +28,6 @@
   createRevisions,
   createComment,
 } from '../../../test/test-data-generators.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
@@ -624,6 +623,76 @@
       assert.isNotOk(args[3]);
     });
 
+    test('A fires an error event when not logged in', done => {
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+        assert.isNull(window.sessionStorage.getItem(
+            'changeView.showReplyDialog'));
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('A navigates to change with logged in', done => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 5,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, commit: {parents: []}},
+        },
+      };
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.changeViewState.showReplyDialog);
+        assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+            5), 'Should navigate to /c/42/5..10');
+        assert.isFalse(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('A navigates to change with old patch number with logged in', done => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.changeViewState.showReplyDialog);
+        assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
+            PARENT), 'Should navigate to /c/42/1');
+        assert.isFalse(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
     test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
@@ -644,19 +713,6 @@
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
-
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
           5), 'Should navigate to /c/42/5..10');
@@ -717,19 +773,6 @@
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-          PARENT), 'Should navigate to /c/42/1');
-
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
           PARENT), 'Should navigate to /c/42/1');
@@ -1872,7 +1915,7 @@
         'a/b/test.c': {},
       };
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
-      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+
       stubRestApi('getProjectConfig').returns(Promise.resolve({}));
       stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
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 6974a76..2e97765 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -22,10 +22,9 @@
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
-import '../gr-ranged-comment-chip/gr-ranged-comment-chip';
+import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-diff_html';
 import {LineNumber} from './gr-diff-line';
@@ -113,9 +112,7 @@
 }
 
 @customElement('gr-diff')
-export class GrDiff extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiff extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -298,17 +295,17 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._observeNodes();
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
-    super.detached();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    super.disconnectedCallback();
   }
 
   showNoChangeMessage(
@@ -755,7 +752,7 @@
       this.classList.add('no-left');
     }
     if (renderPrefs.disable_context_control_buttons) {
-      this.updateStyles({'--context-control-display': 'none'});
+      this.classList.add('disable-context-control-buttons');
     }
   }
 
@@ -804,19 +801,21 @@
 
     const keyLocations = this._computeKeyLocations();
     const bypassPrefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.render(keyLocations, bypassPrefs).then(() => {
-      this.dispatchEvent(
-        new CustomEvent('render', {
-          bubbles: true,
-          composed: true,
-          detail: {contentRendered: true},
-        })
-      );
-    });
+    this.$.diffBuilder
+      .render(keyLocations, bypassPrefs, this.renderPrefs)
+      .then(() => {
+        this.dispatchEvent(
+          new CustomEvent('render', {
+            bubbles: true,
+            composed: true,
+            detail: {contentRendered: true},
+          })
+        );
+      });
   }
 
   _handleRenderContent() {
-    this.querySelectorAll('gr-ranged-comment-chip').forEach(element =>
+    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
       element.remove()
     );
     this._setLoading(false);
@@ -864,14 +863,14 @@
 
         const slotAtt = threadEl.getAttribute('slot');
         if (range && isLongCommentRange(range) && slotAtt) {
-          const longRangeCommentChip = document.createElement(
-            'gr-ranged-comment-chip'
+          const longRangeCommentHint = document.createElement(
+            'gr-ranged-comment-hint'
           );
-          longRangeCommentChip.range = range;
-          longRangeCommentChip.setAttribute('threadElRootId', threadEl.rootId);
-          longRangeCommentChip.setAttribute('slot', slotAtt);
-          this.insertBefore(longRangeCommentChip, threadEl);
-          this._redispatchHoverEvents(longRangeCommentChip, threadEl);
+          longRangeCommentHint.range = range;
+          longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
+          longRangeCommentHint.setAttribute('slot', slotAtt);
+          this.insertBefore(longRangeCommentHint, threadEl);
+          this._redispatchHoverEvents(longRangeCommentHint, threadEl);
         }
 
         // Create a slot for the thread and attach it to the thread group.
@@ -896,7 +895,7 @@
       const removedThreadEls = info.removedNodes.filter(isThreadEl);
       for (const threadEl of removedThreadEls) {
         this.querySelector(
-          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
         )?.remove();
       }
     });
@@ -905,7 +904,7 @@
   _portedCommentsWithoutRangeMessage() {
     const div = document.createElement('div');
     const icon = document.createElement('iron-icon');
-    icon.setAttribute('icon', 'gr-icons:info');
+    icon.setAttribute('icon', 'gr-icons:info-outline');
     div.appendChild(icon);
     const span = document.createElement('span');
     span.innerText = 'Original comment position not found in this patchset';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 4d0e566..12803b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -24,6 +24,12 @@
     :host(.no-left) .sideBySide .right:not([data-value]) + td {
       display: none;
     }
+    :host(.disable-context-control-buttons) {
+      --context-control-display: none;
+    }
+    :host(.disable-context-control-buttons) .section {
+      border-right: none;
+    }
     :host {
       font-family: var(--monospace-font-family, ''), 'Roboto Mono';
       font-size: var(--font-size, var(--font-size-code, 12px));
@@ -223,25 +229,22 @@
     }
 
     .delta.dueToMove .movedIn .moveDescription {
-      color: var(--diff-moved-in-background);
-      background-color: var(--diff-moved-in-label-background);
+      color: var(--diff-moved-in-label-color);
     }
     .delta.dueToMove .movedOut .moveDescription {
-      color: var(--diff-moved-out-background);
-      background-color: var(--diff-moved-out-label-background);
+      color: var(--diff-moved-out-label-color);
     }
     .moveLabel {
-      display: flex;
-      justify-content: flex-end;
       font-family: var(--font-family, ''), 'Roboto Mono';
       font-size: var(--font-size-small, 12px);
-    }
-    .delta.dueToMove .moveDescription {
-      border-radius: var(--fully-rounded-radius, 1000px);
+      font-weight: var(--code-hint-font-weight, 500);
+      line-height: var(--line-height-small, 16px);
       padding: var(--spacing-s) var(--spacing-m);
       margin: var(--spacing-s);
-      line-height: var(--line-height-small, 16px);
+    }
+    .delta.dueToMove .moveDescription {
       display: flex;
+      justify-content: flex-end;
     }
 
     .moveDescription iron-icon {
@@ -430,10 +433,16 @@
     }
     td.lost div {
       background-color: var(--blue-50);
-      padding: var(--spacing-s);
+      padding: var(--spacing-s) 0 0 0;
+    }
+    td.lost div:first-of-type {
+      font-family: var(--font-family, 'Roboto');
+      font-size: var(--font-size-normal, 14px);
+      line-height: var(--line-height-normal);
     }
     td.lost iron-icon {
-      margin-right: var(--spacing-s);
+      padding: 0 var(--spacing-s) 0 var(--spacing-m);
+      color: var(--blue-700);
     }
     col.blame {
       display: none;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 49eac72..c6bd8d6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -555,7 +555,7 @@
           .calledWithExactly(fakeLineEl, 42));
     });
 
-    test('adds long range comment chip', async () => {
+    test('adds long range comment hint', async () => {
       const range = {
         start_line: 1,
         end_line: 12,
@@ -580,10 +580,10 @@
       await flush();
 
       assert.deepEqual(
-          element.querySelector('gr-ranged-comment-chip').range, range);
+          element.querySelector('gr-ranged-comment-hint').range, range);
     });
 
-    test('no duplicate range chip for same thread', async () => {
+    test('no duplicate range hint for same thread', async () => {
       const range = {
         start_line: 1,
         end_line: 12,
@@ -596,10 +596,10 @@
       threadEl.setAttribute('line-num', 1);
       threadEl.setAttribute('range', JSON.stringify(range));
       threadEl.setAttribute('slot', 'right-1');
-      const firstChip = document.createElement('gr-ranged-comment-chip');
-      firstChip.range = range;
-      firstChip.setAttribute('threadElRootId', threadEl.rootId);
-      firstChip.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('threadElRootId', threadEl.rootId);
+      firstHint.setAttribute('slot', 'right-1');
       const content = [{
         a: [],
         b: [],
@@ -608,7 +608,7 @@
       }];
       setupSampleDiff({content});
 
-      element.appendChild(firstChip);
+      element.appendChild(firstHint);
       await flush();
       element._handleRenderContent();
       await flush();
@@ -616,10 +616,10 @@
       await flush();
 
       assert.equal(
-          element.querySelectorAll('gr-ranged-comment-chip').length, 1);
+          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
     });
 
-    test('removes long range comment chip when comment is discarded',
+    test('removes long range comment hint when comment is discarded',
         async () => {
           const range = {
             start_line: 1,
@@ -646,7 +646,7 @@
           threadEl.remove();
           await flush();
 
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-chip'));
+          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
         });
 
     suite('change in preferences', () => {
@@ -804,7 +804,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element._safetyBypass, -1);
-      assert.equal(renderStub.firstCall.lastArg.context, -1);
+      assert.equal(renderStub.firstCall.args[1].context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -817,7 +817,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element._safetyBypass);
-      assert.equal(renderStub.firstCall.lastArg.context, 3);
+      assert.equal(renderStub.firstCall.args[1].context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -829,7 +829,7 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element._safetyBypass, 10);
-      assert.equal(renderStub.firstCall.lastArg.context, 10);
+      assert.equal(renderStub.firstCall.args[1].context, 10);
     });
   });
 
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 1ce2506..ad894ce 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
@@ -18,7 +18,6 @@
 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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-patch-range-select_html';
@@ -38,6 +37,7 @@
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
+  BasePatchSetNum,
   ParentPatchSetNum,
   PatchSetNum,
   RevisionInfo,
@@ -58,7 +58,7 @@
 
 export interface PatchRangeChangeDetail {
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
@@ -84,9 +84,7 @@
  * @extends PolymerElement
  */
 @customElement('gr-patch-range-select')
-export class GrPatchRangeSelect extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPatchRangeSelect extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -123,7 +121,7 @@
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 
   @property({type: Object})
   revisions?: RevisionInfo[];
@@ -222,7 +220,7 @@
 
   _computePatchDropdownContent(
     availablePatches?: PatchSet[],
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     _sortedRevisions?: RevisionInfo[],
     changeComments?: ChangeComments
   ): DropdownItem[] | undefined {
@@ -449,7 +447,7 @@
           patchNum: patchSetValue,
         }),
       });
-      detail.basePatchNum = patchSetValue;
+      detail.basePatchNum = patchSetValue as BasePatchSetNum;
     }
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
similarity index 83%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts
rename to polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index 6948283..3d35c26 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -17,11 +17,11 @@
 
 import {customElement, property} from '@polymer/decorators';
 import {CommentRange} from '../../../types/common';
-import {htmlTemplate} from './gr-ranged-comment-chip_html';
+import {htmlTemplate} from './gr-ranged-comment-hint_html';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 
-@customElement('gr-ranged-comment-chip')
-export class GrRangedCommentChip extends PolymerElement {
+@customElement('gr-ranged-comment-hint')
+export class GrRangedCommentHint extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -37,6 +37,6 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-ranged-comment-chip': GrRangedCommentChip;
+    'gr-ranged-comment-hint': GrRangedCommentHint;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
similarity index 74%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.ts
rename to polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
index c2861fa..670bfd2 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
@@ -22,31 +22,25 @@
   </style>
   <style include="shared-styles">
     .row {
-      color: var(--ranged-comment-chip-text-color);
+      color: var(--ranged-comment-hint-text-color);
       display: flex;
       font-family: var(--font-family, ''), 'Roboto Mono';
       font-size: var(--font-size-small, 12px);
+      font-weight: var(--code-hint-font-weight, 500);
       line-height: var(--line-height-small, 16px);
       justify-content: flex-end;
       margin: var(--spacing-xs) 0;
+      padding: var(--spacing-s) var(--spacing-l);
     }
     .icon {
-      color: var(--ranged-comment-chip-text-color);
+      color: var(--ranged-comment-hint-text-color);
       height: var(--line-height-small, 16px);
       width: var(--line-height-small, 16px);
       margin-right: var(--spacing-s);
     }
-    .chip {
-      background-color: var(--ranged-comment-chip-background);
-      border-radius: var(--fully-rounded-radius, 1000px);
-      margin: var(--spacing-s);
-      padding: var(--spacing-s) var(--spacing-m);
-    }
   </style>
   <div class="row rangeHighlight">
-    <div class="chip">
-      <iron-icon class="icon" icon="gr-icons:comment-outline"></iron-icon>
-      [[_computeRangeLabel(range)]]
-    </div>
+    <iron-icon class="icon" icon="gr-icons:comment"></iron-icon>
+    [[_computeRangeLabel(range)]]
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_test.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
similarity index 83%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_test.ts
rename to polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
index 8bce99d..932c367 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
@@ -17,13 +17,13 @@
 
 import '../../../test/common-test-setup-karma';
 import {CommentRange} from '../../../types/common';
-import {GrRangedCommentChip} from './gr-ranged-comment-chip';
+import {GrRangedCommentHint} from './gr-ranged-comment-hint';
 
-suite('gr-ranged-comment-chip tests', () => {
-  let element: GrRangedCommentChip;
+suite('gr-ranged-comment-hint tests', () => {
+  let element: GrRangedCommentHint;
 
   setup(() => {
-    element = fixtureFromElement('gr-ranged-comment-chip').instantiate();
+    element = fixtureFromElement('gr-ranged-comment-hint').instantiate();
   });
 
   test('shows line range', async () => {
@@ -34,7 +34,7 @@
       end_character: 3,
     } as CommentRange;
     await flush();
-    const textDiv = element.root!.querySelector<HTMLDivElement>('.chip');
+    const textDiv = element.root!.querySelector<HTMLDivElement>('.row');
     assert.equal(textDiv!.innerText.trim(), 'Long comment range 2 - 5');
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index f33c2ce..45a7de6 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-ranged-comment-layer_html';
@@ -72,8 +71,7 @@
 const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
 
 @customElement('gr-ranged-comment-layer')
-export class GrRangedCommentLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+export class GrRangedCommentLayer extends LegacyElementMixin(PolymerElement)
   implements DiffLayer {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index ee52ab6..3e67c49 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -18,7 +18,6 @@
 import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
 import {customElement, property} from '@polymer/decorators';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
@@ -37,9 +36,7 @@
 }
 
 @customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSelectionActionBox extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index 1150674..081d28d 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -15,13 +15,8 @@
  * limitations under the License.
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
 import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
@@ -158,52 +153,46 @@
   lastNotify: {left: number; right: number};
 }
 
-@customElement('gr-syntax-layer')
-export class GrSyntaxLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements DiffLayer {
-  static get template() {
-    return html``;
-  }
-
-  @property({type: Object, observer: '_diffChanged'})
+export class GrSyntaxLayer implements DiffLayer {
   diff?: DiffInfo;
 
-  @property({type: Boolean})
   enabled = true;
 
-  @property({type: Array})
-  _baseRanges: SyntaxLayerRange[][] = [];
+  private baseRanges: SyntaxLayerRange[][] = [];
 
-  @property({type: Array})
-  _revisionRanges: SyntaxLayerRange[][] = [];
+  private revisionRanges: SyntaxLayerRange[][] = [];
 
-  @property({type: String})
-  _baseLanguage?: string;
+  private baseLanguage?: string;
 
-  @property({type: String})
-  _revisionLanguage?: string;
+  private revisionLanguage?: string;
 
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
+  private listeners: DiffLayerListener[] = [];
 
-  @property({type: Number})
-  _processHandle: number | null = null;
+  private processHandle: number | null = null;
 
-  @property({type: Object})
-  _processPromise: CancelablePromise<unknown> | null = null;
+  private processPromise: CancelablePromise<unknown> | null = null;
 
-  @property({type: Object})
-  _hljs?: HighlightJS;
+  private hljs?: HighlightJS;
 
   private readonly libLoader = new GrLibLoader();
 
+  init(diff?: DiffInfo) {
+    this.cancel();
+    this.baseRanges = [];
+    this.revisionRanges = [];
+    this.diff = diff;
+  }
+
+  setEnabled(enabled: boolean) {
+    this.enabled = enabled;
+  }
+
   addListener(listener: DiffLayerListener) {
-    this.push('_listeners', listener);
+    this.listeners.push(listener);
   }
 
   removeListener(listener: DiffLayerListener) {
-    this._listeners = this._listeners.filter(f => f !== listener);
+    this.listeners = this.listeners.filter(f => f !== listener);
   }
 
   /**
@@ -231,13 +220,13 @@
 
     // Find the relevant syntax ranges, if any.
     let ranges: SyntaxLayerRange[] = [];
-    if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
-      ranges = this._baseRanges[line.beforeNumber - 1] || [];
+    if (side === 'left' && this.baseRanges.length >= line.beforeNumber) {
+      ranges = this.baseRanges[line.beforeNumber - 1] || [];
     } else if (
       side === 'right' &&
-      this._revisionRanges.length >= line.afterNumber
+      this.revisionRanges.length >= line.afterNumber
     ) {
-      ranges = this._revisionRanges[line.afterNumber - 1] || [];
+      ranges = this.revisionRanges[line.afterNumber - 1] || [];
     }
 
     // Apply the ranges to the element.
@@ -263,24 +252,24 @@
    */
   process() {
     // Cancel any still running process() calls, because they append to the
-    // same _baseRanges and _revisionRanges fields.
+    // same baseRanges and revisionRanges fields.
     this.cancel();
 
     // Discard existing ranges.
-    this._baseRanges = [];
-    this._revisionRanges = [];
+    this.baseRanges = [];
+    this.revisionRanges = [];
 
     if (!this.enabled || !this.diff?.content.length) {
       return Promise.resolve();
     }
 
     if (this.diff.meta_a) {
-      this._baseLanguage = this._getLanguage(this.diff.meta_a);
+      this.baseLanguage = this._getLanguage(this.diff.meta_a);
     }
     if (this.diff.meta_b) {
-      this._revisionLanguage = this._getLanguage(this.diff.meta_b);
+      this.revisionLanguage = this._getLanguage(this.diff.meta_b);
     }
-    if (!this._baseLanguage && !this._revisionLanguage) {
+    if (!this.baseLanguage && !this.revisionLanguage) {
       return Promise.resolve();
     }
 
@@ -295,12 +284,12 @@
 
     const rangesCache = new Map<string, SyntaxLayerRange[]>();
 
-    this._processPromise = util.makeCancelable(
+    this.processPromise = util.makeCancelable(
       this._loadHLJS().then(
         () =>
           new Promise<void>(resolve => {
             const nextStep = () => {
-              this._processHandle = null;
+              this.processHandle = null;
               this._processNextLine(state, rangesCache);
 
               // Move to the next line in the section.
@@ -324,18 +313,18 @@
 
               if (state.lineIndex % 100 === 0) {
                 this._notify(state);
-                this._processHandle = this.async(nextStep, ASYNC_DELAY);
+                this.processHandle = window.setTimeout(nextStep, ASYNC_DELAY);
               } else {
                 nextStep.call(this);
               }
             };
 
-            this._processHandle = this.async(nextStep, 1);
+            this.processHandle = window.setTimeout(nextStep, 1);
           })
       )
     );
-    return this._processPromise.finally(() => {
-      this._processPromise = null;
+    return this.processPromise.finally(() => {
+      this.processPromise = null;
     });
   }
 
@@ -343,21 +332,15 @@
    * Cancel any asynchronous syntax processing jobs.
    */
   cancel() {
-    if (this._processHandle !== null) {
-      this.cancelAsync(this._processHandle);
-      this._processHandle = null;
+    if (this.processHandle !== null) {
+      clearTimeout(this.processHandle);
+      this.processHandle = null;
     }
-    if (this._processPromise) {
-      this._processPromise.cancel();
+    if (this.processPromise) {
+      this.processPromise.cancel();
     }
   }
 
-  _diffChanged() {
-    this.cancel();
-    this._baseRanges = [];
-    this._revisionRanges = [];
-  }
-
   /**
    * Take a string of HTML with the (potentially nested) syntax markers
    * Highlight.js emits and emit a list of text ranges and classes for the
@@ -420,7 +403,7 @@
     rangesCache: Map<string, SyntaxLayerRange[]>
   ) {
     if (!this.diff) return;
-    if (!this._hljs) return;
+    if (!this.hljs) return;
 
     let baseLine;
     let revisionLine;
@@ -439,44 +422,45 @@
         revisionLine = section.b[state.lineIndex];
         state.lineNums.right++;
       }
+      if (section.skip) {
+        state.lineNums.left += section.skip;
+        state.lineNums.right += section.skip;
+        for (let i = 0; i < section.skip; i++) this.revisionRanges.push([]);
+      }
     }
 
     // To store the result of the syntax highlighter.
     let result;
 
     if (
-      this._baseLanguage &&
+      this.baseLanguage &&
       baseLine !== undefined &&
-      this._hljs.getLanguage(this._baseLanguage)
+      this.hljs.getLanguage(this.baseLanguage)
     ) {
-      baseLine = this._workaround(this._baseLanguage, baseLine);
-      result = this._hljs.highlight(
-        this._baseLanguage,
+      baseLine = this._workaround(this.baseLanguage, baseLine);
+      result = this.hljs.highlight(
+        this.baseLanguage,
         baseLine,
         true,
         state.baseContext
       );
-      this.push(
-        '_baseRanges',
-        this._rangesFromString(result.value, rangesCache)
-      );
+      this.baseRanges.push(this._rangesFromString(result.value, rangesCache));
       state.baseContext = result.top;
     }
 
     if (
-      this._revisionLanguage &&
+      this.revisionLanguage &&
       revisionLine !== undefined &&
-      this._hljs.getLanguage(this._revisionLanguage)
+      this.hljs.getLanguage(this.revisionLanguage)
     ) {
-      revisionLine = this._workaround(this._revisionLanguage, revisionLine);
-      result = this._hljs.highlight(
-        this._revisionLanguage,
+      revisionLine = this._workaround(this.revisionLanguage, revisionLine);
+      result = this.hljs.highlight(
+        this.revisionLanguage,
         revisionLine,
         true,
         state.revisionContext
       );
-      this.push(
-        '_revisionRanges',
+      this.revisionRanges.push(
         this._rangesFromString(result.value, rangesCache)
       );
       state.revisionContext = result.top;
@@ -586,20 +570,14 @@
   }
 
   _notifyRange(start: number, end: number, side: Side) {
-    for (const listener of this._listeners) {
+    for (const listener of this.listeners) {
       listener(start, end, side);
     }
   }
 
   _loadHLJS() {
     return this.libLoader.getHLJS().then(hljs => {
-      this._hljs = hljs;
+      this.hljs = hljs;
     });
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-syntax-layer': GrSyntaxLayer;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
index 20106d8..f9100a2 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -20,8 +20,7 @@
 import './gr-syntax-layer.js';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-
-const basicFixture = fixtureFromElement('gr-syntax-layer');
+import {GrSyntaxLayer} from './gr-syntax-layer.js';
 
 suite('gr-syntax-layer tests', () => {
   let diff;
@@ -48,7 +47,7 @@
   }
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = new GrSyntaxLayer();
     diff = getMockDiffResponse();
     element.diff = diff;
   });
@@ -76,7 +75,7 @@
     el.textContent = str;
     const line = new GrDiffLine(GrDiffLineType.REMOVE);
     line.beforeNumber = 12;
-    element._baseRanges[11] = [{
+    element.baseRanges[11] = [{
       start,
       length,
       className,
@@ -103,7 +102,7 @@
     el.textContent = str;
     const line = new GrDiffLine(GrDiffLineType.REMOVE);
     line.beforeNumber = 12;
-    element._baseRanges[11] = [{
+    element.baseRanges[11] = [{
       start,
       length,
       className,
@@ -127,8 +126,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       done();
     });
   });
@@ -145,8 +144,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       done();
     });
   });
@@ -160,8 +159,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       assert.isFalse(loadHLJSSpy.called);
       done();
     });
@@ -183,13 +182,13 @@
       const linesB = diff.meta_b.lines;
 
       assert.isTrue(processNextSpy.called);
-      assert.equal(element._baseRanges.length, linesA);
-      assert.equal(element._revisionRanges.length, linesB);
+      assert.equal(element.baseRanges.length, linesA);
+      assert.equal(element.revisionRanges.length, linesB);
 
       assert.equal(highlightSpy.callCount, linesA + linesB);
 
       // The first line of both sides have a range.
-      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+      let ranges = [element.baseRanges[0], element.revisionRanges[0]];
       for (const range of ranges) {
         assert.equal(range.length, 1);
         assert.equal(range[0].className,
@@ -200,8 +199,8 @@
 
       // There are no ranges from ll.1-12 on the left and ll.1-11 on the
       // right.
-      ranges = element._baseRanges.slice(1, 12)
-          .concat(element._revisionRanges.slice(1, 11));
+      ranges = element.baseRanges.slice(1, 12)
+          .concat(element.revisionRanges.slice(1, 11));
 
       for (const range of ranges) {
         assert.equal(range.length, 0);
@@ -209,7 +208,7 @@
 
       // There should be another pair of ranges on l.13 for the left and
       // l.12 for the right.
-      ranges = [element._baseRanges[13], element._revisionRanges[12]];
+      ranges = [element.baseRanges[13], element.revisionRanges[12]];
 
       for (const range of ranges) {
         assert.equal(range.length, 1);
@@ -221,13 +220,13 @@
 
       // The next group should have a similar instance on either side.
 
-      let range = element._baseRanges[15];
+      let range = element.baseRanges[15];
       assert.equal(range.length, 1);
       assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
       assert.equal(range[0].start, 34);
       assert.equal(range[0].length, 'ipsum'.length);
 
-      range = element._revisionRanges[14];
+      range = element.revisionRanges[14];
       assert.equal(range.length, 1);
       assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
       assert.equal(range[0].start, 35);
@@ -237,9 +236,9 @@
     });
   });
 
-  test('_diffChanged calls cancel', () => {
-    const cancelSpy = sinon.spy(element, '_diffChanged');
-    element.diff = {content: []};
+  test('init calls cancel', () => {
+    const cancelSpy = sinon.spy(element, 'cancel');
+    element.init({content: []});
     assert.isTrue(cancelSpy.called);
   });
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 7df3d75..580571d 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -17,7 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-documentation-search_html';
@@ -33,7 +32,7 @@
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -57,8 +56,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     fireTitleChange(this, 'Documentation Search');
   }
 
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 4c63303..eff2d94 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-default-editor_html';
@@ -33,9 +32,7 @@
 
 @customElement('gr-default-editor')
 /** @extends PolymerElement */
-export class GrDefaultEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDefaultEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index bc153ee..c8f91cc 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -22,7 +22,6 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-controls_html';
@@ -49,9 +48,7 @@
 }
 
 @customElement('gr-edit-controls')
-export class GrEditControls extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditControls extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -167,7 +164,7 @@
       if (autocomplete) {
         autocomplete.focus();
       }
-      this.async(() => {
+      setTimeout(() => {
         this.$.overlay.center();
       }, 1);
     });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 9f3d1bc..609cda2 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-file-controls_html';
@@ -31,9 +30,7 @@
 
 /** @extends PolymerElement */
 @customElement('gr-edit-file-controls')
-class GrEditFileControls extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrEditFileControls extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 1e08a5c..86dd5b6 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
@@ -21,7 +21,6 @@
 import '../../shared/gr-storage/gr-storage';
 import '../gr-default-editor/gr-default-editor';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-editor-view_html';
@@ -60,7 +59,7 @@
 
 @customElement('gr-editor-view')
 export class GrEditorView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -141,16 +140,17 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getEditPrefs().then(prefs => {
       this._prefs = prefs;
     });
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_STORE);
+    super.disconnectedCallback();
   }
 
   get storageKey() {
@@ -179,7 +179,7 @@
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
-    this.async(() => {
+    setTimeout(() => {
       const title = `Editing ${computeTruncatedPath(value.path)}`;
       fireTitleChange(this, title);
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index ec3bd0f..f33ed8b 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -37,7 +37,6 @@
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-app-element_html';
@@ -80,6 +79,7 @@
 import {EventType} from '../utils/event-util';
 import {GerritView} from '../services/router/router-model';
 import {windowLocationReload} from '../utils/dom-util';
+import {LifeCycle} from '../constants/reporting';
 
 interface ErrorInfo {
   text: string;
@@ -99,7 +99,7 @@
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
 export class GrAppElement extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -254,8 +254,11 @@
 
     this.restApiService.getAccount().then(account => {
       this._account = account;
-      const role = account ? 'user' : 'guest';
-      this.reporting.reportLifeCycle(`Started as ${role}`);
+      if (account) {
+        this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_USER);
+      } else {
+        this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_GUEST);
+      }
     });
     this.restApiService.getConfig().then(config => {
       this._serverConfig = config;
@@ -482,7 +485,7 @@
     // because _showPluginScreen value does not change. To force restamp,
     // change _showPluginScreen value between true and false.
     if (isPluginScreen) {
-      this.async(() => this.set('_showPluginScreen', true), 1);
+      setTimeout(() => this.set('_showPluginScreen', true), 1);
     }
     this.set(
       '_showDocumentationSearch',
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 4809562..c0779a7 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,6 +20,7 @@
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
 import {
+  BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
@@ -100,7 +101,7 @@
   commentId?: UrlEncodedCommentId;
   path?: string;
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   lineNum: number;
   leftSide?: boolean;
   commentLink?: boolean;
@@ -111,7 +112,7 @@
   project: RepoName;
   edit?: boolean;
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   queryMap?: Map<string, string> | URLSearchParams;
 }
 
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index f19931f..2e7618a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -36,7 +36,6 @@
 
 import {initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-app_html';
@@ -47,7 +46,7 @@
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-class GrApp extends GestureEventListeners(LegacyElementMixin(PolymerElement)) {
+class GrApp extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index 897be67..7a91c68 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -16,6 +16,7 @@
  */
 import {EventType, PluginApi} from '../../../api/plugin';
 import {AdminPluginApi, MenuLink} from '../../../api/admin';
+import {appContext} from '../../../services/app-context';
 
 /**
  * GrAdminApi class.
@@ -26,15 +27,20 @@
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(private readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'admin', 'constructor');
     this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
 
   addMenuLink(text: string, url: string, capability?: string) {
+    this.reporting.trackApi(this.plugin, 'admin', 'addMenuLink');
     this.menuLinks.push({text, url, capability: capability || null});
   }
 
   getMenuLinks(): MenuLink[] {
+    this.reporting.trackApi(this.plugin, 'admin', 'getMenuLinks');
     return this.menuLinks.slice(0);
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index e0b4ee9..ab2ce6a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -15,14 +15,20 @@
  * limitations under the License.
  */
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
+import {PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
 
 export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
+  private readonly reporting = appContext.reportingService;
+
   // TODO(TS): Change any to something more like HTMLElement.
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  constructor(public element: any) {}
+  constructor(readonly plugin: PluginApi, public element: any) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'constructor');
+  }
 
   _getChangedEventName(name: string): string {
     return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
@@ -52,6 +58,7 @@
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   bind(name: string, callback: (value: any) => void) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'bind');
     const attributeChangedEventName = this._getChangedEventName(name);
     const changedHandler = (e: CustomEvent) =>
       this._reportValue(callback, e.detail.value);
@@ -72,6 +79,7 @@
    * to be initialized if it isn't defined.
    */
   get(name: string): Promise<unknown> {
+    this.reporting.trackApi(this.plugin, 'attribute', 'get');
     if (this._elementHasProperty(name)) {
       return Promise.resolve(this.element[name]);
     }
@@ -93,6 +101,7 @@
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   set(name: string, value: any) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'set');
     this.element[name] = value;
     this.element.dispatchEvent(
       new CustomEvent(this._getChangedEventName(name), {detail: {value}})
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index 7ea3be3..2d83012 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {GrAttributeHelper} from './gr-attribute-helper.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
   is: 'gr-attribute-helper-some-element',
@@ -31,13 +31,18 @@
 
 const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
 suite('gr-attribute-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
-    instance = new GrAttributeHelper(element);
+    instance = plugin.attributeHelper(element);
   });
 
   test('resolved on value change from undefined', () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index a03c5dc..bbb58fc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -17,15 +17,19 @@
 import {PluginApi} from '../../../api/plugin';
 import {ChangeMetadataPluginApi} from '../../../api/change-metadata';
 import {HookApi} from '../../../api/hook';
+import {appContext} from '../../../services/app-context';
 
 export class GrChangeMetadataApi implements ChangeMetadataPluginApi {
   private hook: HookApi | null;
 
   public plugin: PluginApi;
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(plugin: PluginApi) {
     this.plugin = plugin;
     this.hook = null;
+    this.reporting.trackApi(this.plugin, 'metadata', 'constructor');
   }
 
   _createHook() {
@@ -33,6 +37,7 @@
   }
 
   onLabelsChanged(callback: (value: unknown) => void) {
+    this.reporting.trackApi(this.plugin, 'metadata', 'onLabelsChanged');
     if (!this.hook) {
       this._createHook();
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 404fc71..39d3c8b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -43,13 +43,19 @@
 
   private readonly checksService = appContext.checksService;
 
-  constructor(readonly plugin: PluginApi) {}
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'checks', 'constructor');
+  }
 
   announceUpdate() {
+    this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
     this.checksService.reload(this.plugin.getPluginName());
   }
 
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
+    this.reporting.trackApi(this.plugin, 'checks', 'register');
     if (this.state === State.REGISTERED)
       throw new Error('Only one provider can be registered per plugin.');
     this.state = State.REGISTERED;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 423cff9..b52fef0 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-endpoint-decorator_html';
@@ -30,9 +29,7 @@
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-export class GrEndpointDecorator extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEndpointDecorator extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -54,12 +51,12 @@
   _endpointCallBack: (info: ModuleInfo) => void = () => {};
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     for (const [el, domHook] of this._domHooks) {
       domHook.handleInstanceDetached(el);
     }
     getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+    super.disconnectedCallback();
   }
 
   _initDecoration(
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index 9e9f348..f48bb0f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
@@ -26,9 +25,7 @@
 }
 
 @customElement('gr-endpoint-param')
-export class GrEndpointParam extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEndpointParam extends LegacyElementMixin(PolymerElement) {
   @property({type: String, reflectToAttribute: true})
   name = '';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 4b34d56..0c2f412 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -18,6 +18,8 @@
   EventHelperPluginApi,
   UnsubscribeCallback,
 } from '../../../api/event-helper';
+import {PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
 
 export interface ListenOptions {
   event?: string;
@@ -25,13 +27,18 @@
 }
 
 export class GrEventHelper implements EventHelperPluginApi {
-  constructor(readonly element: HTMLElement) {}
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
+    this.reporting.trackApi(this.plugin, 'event', 'constructor');
+  }
 
   /**
    * Add a callback to arbitrary event.
    * The callback may return false to prevent event bubbling.
    */
   on(event: string, callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'on');
     return this._listen(this.element, callback, {event});
   }
 
@@ -39,6 +46,7 @@
    * Alias for @see onClick
    */
   onTap(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'onTap');
     return this.onClick(callback);
   }
 
@@ -47,6 +55,7 @@
    * The callback may return false to prevent event bubbling.
    */
   onClick(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'onClick');
     return this._listen(this.element, callback);
   }
 
@@ -54,6 +63,7 @@
    * Alias for @see captureClick
    */
   captureTap(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'captureTap');
     return this.captureClick(callback);
   }
 
@@ -64,6 +74,7 @@
    * The callback may return false to cancel regular event listeners.
    */
   captureClick(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'captureClick');
     const parent = this.element.parentElement!;
     return this._listen(parent, callback, {capture: true});
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 25c0d43..547b575 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -17,8 +17,8 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {GrEventHelper} from './gr-event-helper.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
   is: 'gr-event-helper-some-element',
@@ -33,13 +33,18 @@
 
 const basicFixture = fixtureFromElement('gr-event-helper-some-element');
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
 suite('gr-event-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
-    instance = new GrEventHelper(element);
+    instance = plugin.eventHelper(element);
   });
 
   test('onTap()', done => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
index dc3ebcf..25cc354 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-external-style_html';
@@ -24,9 +23,7 @@
 import {customElement, property} from '@polymer/decorators';
 
 @customElement('gr-external-style')
-class GrExternalStyle extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrExternalStyle extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -67,8 +64,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._importAndApply();
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index 81b3a16..70087a5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -22,9 +21,7 @@
 import {ServerInfo} from '../../../types/common';
 
 @customElement('gr-plugin-host')
-class GrPluginHost extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrPluginHost extends LegacyElementMixin(PolymerElement) {
   @property({type: Object, observer: '_configChanged'})
   config?: ServerInfo;
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
index 7c6587a..227fab1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-popup_html';
@@ -34,9 +33,7 @@
   };
 }
 @customElement('gr-plugin-popup')
-export class GrPluginPopup extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPluginPopup extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index dcabc80..13d18b5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -19,6 +19,7 @@
 import {GrPluginPopup} from './gr-plugin-popup';
 import {PluginApi} from '../../../api/plugin';
 import {PopupPluginApi} from '../../../api/popup';
+import {appContext} from '../../../services/app-context';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -35,10 +36,14 @@
 
   private popup: GrPluginPopup | null = null;
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     readonly plugin: PluginApi,
     private moduleName: string | null = null
-  ) {}
+  ) {
+    this.reporting.trackApi(this.plugin, 'popup', 'constructor');
+  }
 
   _getElement() {
     // TODO(TS): maybe consider removing this if no one is using
@@ -52,6 +57,7 @@
    * if it was provided with constructor.
    */
   open(): Promise<PopupPluginApi> {
+    this.reporting.trackApi(this.plugin, 'popup', 'open');
     if (!this.openingPromise) {
       this.openingPromise = this.plugin
         .hook('plugin-overlay')
@@ -76,6 +82,7 @@
    * Hides the popup.
    */
   close() {
+    this.reporting.trackApi(this.plugin, 'popup', 'close');
     if (!this.popup) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
index 0418edb..51e9112 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -19,6 +19,7 @@
 import {PluginApi} from '../../../api/plugin';
 import {RepoCommandCallback, RepoPluginApi} from '../../../api/repo';
 import {HookApi} from '../../../api/hook';
+import {appContext} from '../../../services/app-context';
 
 /**
  * Parameters provided on repo-command endpoint
@@ -31,7 +32,11 @@
 export class GrRepoApi implements RepoPluginApi {
   private hook?: HookApi;
 
-  constructor(readonly plugin: PluginApi) {}
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'repo', 'constructor');
+  }
 
   // TODO(TS): should mark as public since used in gr-change-metadata-api
   _createHook(title: string) {
@@ -43,6 +48,7 @@
   }
 
   createCommand(title: string, callback: RepoCommandCallback) {
+    this.reporting.trackApi(this.plugin, 'repo', 'createCommand');
     if (this.hook) {
       console.warn('Already set up.');
       return this;
@@ -57,6 +63,7 @@
   }
 
   onTap(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'repo', 'onTap');
     if (!this.hook) {
       console.warn('Call createCommand first.');
       return this;
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
index 4bdd40e..3f75c0a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
@@ -18,6 +18,7 @@
 import '../../settings/gr-settings-view/gr-settings-menu-item';
 import {PluginApi} from '../../../api/plugin';
 import {SettingsPluginApi} from '../../../api/settings';
+import {appContext} from '../../../services/app-context';
 
 export class GrSettingsApi implements SettingsPluginApi {
   private _token: string;
@@ -26,27 +27,34 @@
 
   private _moduleName?: string;
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'settings', 'constructor');
     // Generate default screen URL token, specific to plugin, and unique(ish).
     this._token = plugin.getPluginName() + Math.random().toString(36).substr(5);
   }
 
   title(newTitle: string) {
+    this.reporting.trackApi(this.plugin, 'settings', 'title');
     this._title = newTitle;
     return this;
   }
 
   token(newToken: string) {
+    this.reporting.trackApi(this.plugin, 'settings', 'token');
     this._token = newToken;
     return this;
   }
 
   module(newModuleName: string) {
+    this.reporting.trackApi(this.plugin, 'settings', 'module');
     this._moduleName = newModuleName;
     return this;
   }
 
   build() {
+    this.reporting.trackApi(this.plugin, 'settings', 'build');
     if (!this._moduleName) {
       throw new Error('Settings screen custom element not defined!');
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
index a91b8d3..9a15bf5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 import {StyleObject, StylesPluginApi} from '../../../api/styles';
+import {appContext} from '../../../services/app-context';
+import {PluginApi} from '../../../api/plugin';
 
 /**
  * @fileoverview We should consider dropping support for this API:
@@ -77,10 +79,17 @@
  * TODO(TS): move to util
  */
 export class GrStylesApi implements StylesPluginApi {
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'styles', 'constructor');
+  }
+
   /**
    * Creates a new GrStyleObject with specified style properties.
    */
   css(ruleStr: string) {
+    this.reporting.trackApi(this.plugin, 'styles', 'css');
     return new GrStyleObject(ruleStr);
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
index 894ec6c..c7be7f6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -18,14 +18,20 @@
 import {GrCustomPluginHeader} from './gr-custom-plugin-header';
 import {PluginApi} from '../../../api/plugin';
 import {ThemePluginApi} from '../../../api/theme';
+import {appContext} from '../../../services/app-context';
 
 /**
  * Defines api for theme, can be used to set header logo and title.
  */
 export class GrThemeApi implements ThemePluginApi {
-  constructor(private readonly plugin: PluginApi) {}
+  private readonly reporting = appContext.reportingService;
+
+  constructor(private readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'theme', 'constructor');
+  }
 
   setHeaderLogoAndTitle(logoUrl: string, title: string) {
+    this.reporting.trackApi(this.plugin, 'theme', 'setHeaderLogoAndTitle');
     this.plugin.hook('header-title', {replace: true}).onAttached(element => {
       const customHeader: GrCustomPluginHeader = document.createElement(
         'gr-custom-plugin-header'
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 5786576..1bb32a1 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-info_html';
@@ -30,9 +29,7 @@
 import {fireEvent} from '../../../utils/event-util';
 
 @customElement('gr-account-info')
-export class GrAccountInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountInfo extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index fe4ef8e..01ebfbd 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -67,13 +67,16 @@
       <span class="title">Username</span>
       <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
       <span hidden$="[[!usernameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_username}}"
+          id="usernameIronInput"
+        >
           <input
             is="iron-input"
             id="usernameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_username}}"
           />
         </iron-input>
       </span>
@@ -82,13 +85,16 @@
       <label class="title" for="nameInput">Full name</label>
       <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
       <span hidden$="[[!nameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.name}}"
+          id="nameIronInput"
+        >
           <input
             is="iron-input"
             id="nameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.name}}"
           />
         </iron-input>
       </span>
@@ -105,7 +111,6 @@
             id="displayNameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.display_name}}"
           />
         </iron-input>
       </span>
@@ -122,7 +127,6 @@
             id="statusInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.status}}"
           />
         </iron-input>
       </span>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
similarity index 67%
rename from polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
rename to polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index c7cb44e..6aa7f7c 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -15,52 +15,63 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-info.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-info';
+import {SinonSpyMember, stubRestApi} from '../../../test/test-utils';
+import {GrAccountInfo} from './gr-account-info';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {
+  createAccountWithIdNameAndEmail,
+  createPreferences,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {IronInputElement} from '@polymer/iron-input';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
 
 suite('gr-account-info tests', () => {
-  let element;
-  let account;
-  let config;
+  let element!: GrAccountInfo;
+  let account: AccountDetailInfo;
+  let config: ServerInfo;
 
-  function valueOf(title) {
-    const sections = element.root.querySelectorAll('section');
+  function queryIronInput(selector: string): IronInputElement {
+    const input = element.root?.querySelector<IronInputElement>(selector);
+    if (!input) assert.fail(`<iron-input> with id ${selector} not found.`);
+    return input;
+  }
+
+  function valueOf(title: string): Element {
+    const sections = element.root?.querySelectorAll('section') ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent === title) {
-        return sections[i].querySelector('.value');
+      if (titleEl?.textContent === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
       }
     }
+    assert.fail(`element with title ${title} not found`);
   }
 
-  setup(done => {
-    account = {
-      _account_id: 123,
-      name: 'user name',
-      email: 'user@email',
-      username: 'user username',
-      registered: '2000-01-01 00:00:00.000000000',
-    };
-    config = {auth: {editable_account_fields: []}};
+  setup(async () => {
+    account = createAccountWithIdNameAndEmail(123) as AccountDetailInfo;
+    config = createServerInfo();
 
     stubRestApi('getAccount').returns(Promise.resolve(account));
     stubRestApi('getConfig').returns(Promise.resolve(config));
-    stubRestApi('getPreferences').returns(
-        Promise.resolve({time_format: 'HHMM_12'}));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
 
     element = basicFixture.instantiate();
-    // Allow the element to render.
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('basic account info render', () => {
     assert.isFalse(element._loading);
 
-    assert.equal(valueOf('ID').textContent, account._account_id);
+    assert.equal(valueOf('ID').textContent, `${account._account_id}`);
     assert.equal(valueOf('Email').textContent, account.email);
     assert.equal(valueOf('Username').textContent, account.username);
   });
@@ -77,8 +88,9 @@
   });
 
   test('full name render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['FULL_NAME']}});
+    element.set('_serverConfig', {
+      auth: {editable_account_fields: ['FULL_NAME']},
+    });
 
     const section = element.$.nameSection;
     const displaySpan = section.querySelectorAll('.value')[0];
@@ -86,7 +98,7 @@
 
     assert.isTrue(element.nameMutable);
     assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.nameInput.bindValue, account.name);
+    assert.equal(queryIronInput('#nameIronInput').bindValue, account.name);
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
@@ -102,8 +114,9 @@
   });
 
   test('username render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['USER_NAME']}});
+    element.set('_serverConfig', {
+      auth: {editable_account_fields: ['USER_NAME']},
+    });
     element.set('_account.username', '');
     element.set('_username', '');
 
@@ -113,32 +126,34 @@
 
     assert.isTrue(element.usernameMutable);
     assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.usernameInput.bindValue, account.username);
+    assert.equal(
+      queryIronInput('#usernameIronInput').bindValue,
+      account.username
+    );
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
   suite('account info edit', () => {
-    let nameChangedSpy;
-    let usernameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let usernameStub;
-    let statusStub;
+    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
+    let usernameChangedSpy: SinonSpyMember<typeof element._usernameChanged>;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
+    let usernameStub: SinonStubbedMember<RestApiService['setAccountUsername']>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       nameChangedSpy = sinon.spy(element, '_nameChanged');
       usernameChangedSpy = sinon.spy(element, '_usernameChanged');
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
+      element.set('_serverConfig', {
+        auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']},
+      });
 
-      nameStub = stubRestApi('setAccountName').callsFake(
-          name => Promise.resolve());
-      usernameStub = stubRestApi('setAccountUsername')
-          .callsFake(username => Promise.resolve());
-      statusStub = stubRestApi(
-          'setAccountStatus').callsFake(
-          status => Promise.resolve());
+      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
+      usernameStub = stubRestApi('setAccountUsername').returns(
+        Promise.resolve()
+      );
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
     test('name', done => {
@@ -206,24 +221,21 @@
   });
 
   suite('edit name and status', () => {
-    let nameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let statusStub;
+    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       nameChangedSpy = sinon.spy(element, '_nameChanged');
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+      element.set('_serverConfig', {
+        auth: {editable_account_fields: ['FULL_NAME']},
+      });
 
-      nameStub = stubRestApi('setAccountName').callsFake(
-          name => Promise.resolve());
-      statusStub = stubRestApi(
-          'setAccountStatus').callsFake(
-          status => Promise.resolve());
-      stubRestApi('setAccountUsername').callsFake(
-          username => Promise.resolve());
+      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      stubRestApi('setAccountUsername').returns(Promise.resolve());
     });
 
     test('set name and status', done => {
@@ -254,17 +266,14 @@
   });
 
   suite('set status but read name', () => {
-    let statusChangedSpy;
-    let statusStub;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: []}});
+      element.set('_serverConfig', {auth: {editable_account_fields: []}});
 
-      statusStub = stubRestApi(
-          'setAccountStatus').callsFake(
-          status => Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
     test('read full name but set status', done => {
@@ -320,4 +329,3 @@
     assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index da180c3..6768497 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -17,7 +17,6 @@
 
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-agreements-list_html';
@@ -27,9 +26,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-agreements-list')
-export class GrAgreementsList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAgreementsList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -40,8 +37,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
rename to polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
index c8ce574..f08976f 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -14,41 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-agreements-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-agreements-list';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrAgreementsList} from './gr-agreements-list';
+import {ContributorAgreementInfo} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-agreements-list');
 
 suite('gr-agreements-list tests', () => {
-  let element;
-  let agreements;
+  let element: GrAgreementsList;
 
   setup(done => {
-    agreements = [{
-      url: 'some url',
-      description: 'Agreements 1 description',
-      name: 'Agreements 1',
-    }];
+    const agreements: ContributorAgreementInfo[] = [
+      {
+        url: 'some url',
+        description: 'Agreements 1 description',
+        name: 'Agreements 1',
+      },
+    ];
 
     stubRestApi('getAccountAgreements').returns(Promise.resolve(agreements));
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    element.loadData().then(() => {
+      flush(done);
+    });
   });
 
   test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
+    const rows = element.root?.querySelectorAll('tbody tr') ?? [];
     assert.equal(rows.length, 1);
 
     const nameCells = Array.from(rows).map(row =>
-      row.querySelectorAll('td')[0].textContent.trim()
+      row.querySelectorAll('td')[0].textContent?.trim()
     );
 
     assert.equal(nameCells[0], 'Agreements 1');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index df927d6..970fb5f 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-table-editor_html';
@@ -29,15 +28,15 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-change-table-editor')
-class GrChangeTableEditor extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+export class GrChangeTableEditor extends ChangeTableMixin(
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
   }
 
   @property({type: Array, notify: true})
-  displayedColumns?: string[];
+  displayedColumns: string[] = [];
 
   @property({type: Boolean, notify: true})
   showNumber?: boolean;
@@ -46,7 +45,7 @@
   serverConfig?: ServerInfo;
 
   @property({type: Array})
-  defaultColumns?: string[];
+  defaultColumns: string[] = [];
 
   flagsService = appContext.flagsService;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
deleted file mode 100644
index db5c035..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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-change-table-editor.js';
-
-const basicFixture = fixtureFromElement('gr-change-table-editor');
-
-suite('gr-change-table-editor tests', () => {
-  let element;
-  let columns;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-    ];
-
-    element.set('displayedColumns', columns);
-    element.showNumber = false;
-    element.serverConfig = {
-      change: {},
-    };
-    flush();
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('tr');
-    let tds;
-
-    // The `+ 1` is for the number column, which isn't included in the change
-    // table behavior's list.
-    assert.equal(rows.length, element.defaultColumns.length + 1);
-    for (let i = 0; i < element.defaultColumns.length; i++) {
-      tds = rows[i + 1].querySelectorAll('td');
-      assert.equal(tds[0].textContent, element.defaultColumns[i]);
-    }
-  });
-
-  test('disabled experiments are hidden', () => {
-    assert.isFalse(element.displayedColumns.includes('Assignee'));
-    element.set('displayedColumns', columns);
-    element.serverConfig = {
-      change: {
-        enable_assignee: true,
-      },
-    };
-    flush();
-    assert.isTrue(element.displayedColumns.includes('Assignee'));
-  });
-
-  test('hide item', () => {
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isTrue(isChecked);
-
-    MockInteractions.tap(checkbox);
-    flush();
-
-    assert.equal(element.displayedColumns.length, displayedLength - 1);
-  });
-
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
-    // trigger computation of enabled displayed columns
-    element.serverConfig = {
-      change: {},
-    };
-    flush();
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isFalse(isChecked);
-    assert.equal(element.shadowRoot
-        .querySelector('table').style.display, '');
-
-    MockInteractions.tap(checkbox);
-    flush();
-
-    assert.equal(element.displayedColumns.length,
-        displayedLength + 1);
-  });
-
-  test('_getDisplayedColumns', () => {
-    const enabledColumns = columns.filter(column => element.isColumnEnabled(
-        column, element.serverConfig, []
-    ));
-    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Subject]'));
-    assert.deepEqual(element._getDisplayedColumns(),
-        enabledColumns.filter(c => c !== 'Subject'));
-  });
-
-  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
-    sinon.stub(element, '_handleNumberCheckboxClick');
-    sinon.stub(element, '_handleTargetClick');
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:first-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isFalse(element._handleTargetClick.called);
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:last-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element._handleTargetClick.calledOnce);
-  });
-
-  test('_handleNumberCheckboxClick', () => {
-    sinon.spy(element, '_handleNumberCheckboxClick');
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element.showNumber);
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
-    assert.isFalse(element.showNumber);
-  });
-
-  test('_handleTargetClick', () => {
-    sinon.spy(element, '_handleTargetClick');
-    assert.include(element.displayedColumns, 'Subject');
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Subject]'));
-    assert.isTrue(element._handleTargetClick.calledOnce);
-    assert.notInclude(element.displayedColumns, 'Subject');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
new file mode 100644
index 0000000..4f61972
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -0,0 +1,182 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import './gr-change-table-editor';
+import {GrChangeTableEditor} from './gr-change-table-editor';
+import {queryAndAssert} from '../../../test/test-utils';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {ServerInfo} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-change-table-editor');
+
+suite('gr-change-table-editor tests', () => {
+  let element: GrChangeTableEditor;
+  let columns: string[];
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
+
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    element.serverConfig = createServerInfo();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = queryAndAssert(element, 'tbody').querySelectorAll('tr');
+    let tds;
+
+    // The `+ 1` is for the number column, which isn't included in the change
+    // table behavior's list.
+    assert.equal(rows.length, element.defaultColumns.length + 1);
+    for (let i = 0; i < element.defaultColumns.length; i++) {
+      tds = rows[i + 1].querySelectorAll('td');
+      assert.equal(tds[0].textContent, element.defaultColumns[i]);
+    }
+  });
+
+  test('disabled experiments are hidden', () => {
+    assert.isFalse(element.displayedColumns.includes('Assignee'));
+    element.set('displayedColumns', columns);
+    const config: ServerInfo = {...createServerInfo()};
+    config.change.enable_assignee = true;
+    element.serverConfig = config;
+    flush();
+    assert.isTrue(element.displayedColumns.includes('Assignee'));
+  });
+
+  test('hide item', () => {
+    const checkbox = queryAndAssert<HTMLInputElement>(
+      element,
+      'table tr:nth-child(2) input'
+    );
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isTrue(isChecked);
+
+    MockInteractions.tap(checkbox);
+    flush();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    // trigger computation of enabled displayed columns
+    element.serverConfig = createServerInfo();
+    flush();
+    const checkbox = queryAndAssert<HTMLInputElement>(
+      element,
+      'table tr:nth-child(2) input'
+    );
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isFalse(isChecked);
+    const table = queryAndAssert<HTMLTableElement>(element, 'table');
+    assert.equal(table.style.display, '');
+
+    MockInteractions.tap(checkbox);
+    flush();
+
+    assert.equal(element.displayedColumns.length, displayedLength + 1);
+  });
+
+  test('_getDisplayedColumns', () => {
+    const enabledColumns = columns.filter(column =>
+      element.isColumnEnabled(column, element.serverConfig!, [])
+    );
+    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
+    const input = queryAndAssert<HTMLInputElement>(
+      element,
+      '.checkboxContainer input[name=Subject]'
+    );
+    MockInteractions.tap(input);
+    assert.deepEqual(
+      element._getDisplayedColumns(),
+      enabledColumns.filter(c => c !== 'Subject')
+    );
+  });
+
+  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
+    const checkBoxClickStub = sinon.stub(element, '_handleNumberCheckboxClick');
+    const targetClickStub = sinon.stub(element, '_handleTargetClick');
+
+    const firstContainer = queryAndAssert(
+      element,
+      'table tr:first-of-type .checkboxContainer'
+    );
+    MockInteractions.tap(firstContainer);
+    assert.isTrue(checkBoxClickStub.calledOnce);
+    assert.isFalse(targetClickStub.called);
+
+    const lastContainer = queryAndAssert(
+      element,
+      'table tr:last-of-type .checkboxContainer'
+    );
+    MockInteractions.tap(lastContainer);
+    assert.isTrue(checkBoxClickStub.calledOnce);
+    assert.isTrue(targetClickStub.calledOnce);
+  });
+
+  test('_handleNumberCheckboxClick', () => {
+    const checkBoxClickSpy = sinon.spy(element, '_handleNumberCheckboxClick');
+
+    const numberInput = queryAndAssert(
+      element,
+      '.checkboxContainer input[name=number]'
+    );
+    MockInteractions.tap(numberInput);
+    assert.isTrue(checkBoxClickSpy.calledOnce);
+    assert.isTrue(element.showNumber);
+
+    MockInteractions.tap(numberInput);
+    assert.isTrue(checkBoxClickSpy.calledTwice);
+    assert.isFalse(element.showNumber);
+  });
+
+  test('_handleTargetClick', () => {
+    const targetClickSpy = sinon.spy(element, '_handleTargetClick');
+    assert.include(element.displayedColumns, 'Subject');
+    const subjectInput = queryAndAssert(
+      element,
+      '.checkboxContainer input[name=Subject]'
+    );
+    MockInteractions.tap(subjectInput);
+    assert.isTrue(targetClickSpy.calledOnce);
+    assert.notInclude(element.displayedColumns, 'Subject');
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 9f7fadf..2ae1634 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -19,7 +19,6 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-cla-view_html';
@@ -40,9 +39,7 @@
 }
 
 @customElement('gr-cla-view')
-export class GrClaView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrClaView extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -71,8 +68,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
 
     fireTitleChange(this, 'New Contributor Agreement');
@@ -155,7 +152,7 @@
 
   _disableAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
+    groups?: GroupInfo[],
     signedAgreements?: ContributorAgreementInfo[]
   ) {
     if (!groups) return false;
@@ -189,7 +186,7 @@
   // then hides the text box and submit button.
   _computeHideAgreementClass(
     name: string,
-    contributorAgreements: ContributorAgreementInfo[]
+    contributorAgreements?: ContributorAgreementInfo[]
   ) {
     if (!contributorAgreements) return '';
     return contributorAgreements.some(
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
deleted file mode 100644
index 7c11136..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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-cla-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-cla-view');
-
-suite('gr-cla-view tests', () => {
-  let element;
-  const signedAgreements = [{
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla.html',
-  }];
-  const auth = {
-    name: 'Individual',
-    description: 'test-description',
-    url: 'static/cla_individual.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      options: {
-        visible_to_all: true,
-      },
-      group_id: 20,
-      owner: 'CLA Accepted - Individual',
-      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      created_on: '2017-07-31 15:11:04.000000000',
-      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      name: 'CLA Accepted - Individual',
-    },
-  };
-
-  const auth2 = {
-    name: 'Individual2',
-    description: 'test-description2',
-    url: 'static/cla_individual2.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      options: {},
-      group_id: 21,
-      owner: 'CLA Accepted - Individual2',
-      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      created_on: '2017-07-31 15:25:42.000000000',
-      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      name: 'CLA Accepted - Individual2',
-    },
-  };
-
-  const auth3 = {
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla_individual.html',
-  };
-
-  const config = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual',
-          description: 'test-description',
-          url: 'static/cla_individual.html',
-        },
-        {
-          name: 'CLA',
-          description: 'Contributor License Agreement',
-          url: 'static/cla.html',
-        }],
-    },
-  };
-  const config2 = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual2',
-          description: 'test-description2',
-          url: 'static/cla_individual2.html',
-        },
-      ],
-    },
-  };
-  const groups = [{
-    options: {visible_to_all: true},
-    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-    group_id: 3,
-    name: 'CLA Accepted - Individual',
-  },
-  ];
-
-  setup(done => {
-    stubRestApi('getConfig').returns(Promise.resolve(config));
-    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
-    stubRestApi('getAccountAgreements').returns(
-        Promise.resolve(signedAgreements));
-    element = basicFixture.instantiate();
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders as expected with signed agreement', () => {
-    const agreementSections = dom(element.root)
-        .querySelectorAll('.contributorAgreementButton');
-    const agreementSubmittedTexts = dom(element.root)
-        .querySelectorAll('.alreadySubmittedText');
-    assert.equal(agreementSections.length, 2);
-    assert.isFalse(agreementSections[0].querySelector('input').disabled);
-    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
-        'none');
-    assert.isTrue(agreementSections[1].querySelector('input').disabled);
-    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
-        'none');
-  });
-
-  test('_disableAgreements', () => {
-    // In the auto verify group and have not yet signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth, groups, signedAgreements));
-    // Not in the auto verify group and have not yet signed agreement
-    assert.isFalse(
-        element._disableAgreements(auth2, groups, signedAgreements));
-    // Not in the auto verify group, have signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth3, groups, signedAgreements));
-    // Make sure the undefined check works
-    assert.isFalse(
-        element._disableAgreements(auth, undefined, signedAgreements));
-  });
-
-  test('_hideAgreements', () => {
-    // Not in the auto verify group and have not yet signed agreement
-    assert.equal(
-        element._hideAgreements(auth, groups, signedAgreements), '');
-    // In the auto verify group
-    assert.equal(
-        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
-    // Not in the auto verify group, have signed agreement
-    assert.equal(
-        element._hideAgreements(auth3, groups, signedAgreements), '');
-  });
-
-  test('_disableAgreementsText', () => {
-    assert.isFalse(element._disableAgreementsText('I AGREE'));
-    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-  });
-
-  test('_computeHideAgreementClass', () => {
-    assert.equal(
-        element._computeHideAgreementClass(
-            auth.name, config.auth.contributor_agreements),
-        'hideAgreementsTextBox');
-    assert.isNotOk(
-        element._computeHideAgreementClass(
-            auth.name, config2.auth.contributor_agreements));
-  });
-
-  test('_getAgreementsUrl', () => {
-    assert.equal(element._getAgreementsUrl(
-        'http://test.org/test.html'), 'http://test.org/test.html');
-    assert.equal(element._getAgreementsUrl(
-        'test_cla.html'), '/test_cla.html');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
new file mode 100644
index 0000000..9f54cd1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -0,0 +1,210 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-cla-view';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrClaView} from './gr-cla-view';
+import {
+  ContributorAgreementInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  ServerInfo,
+} from '../../../types/common';
+import {AuthType} from '../../../constants/constants';
+import {createServerInfo} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-cla-view');
+
+suite('gr-cla-view tests', () => {
+  let element: GrClaView;
+  const signedAgreements = [
+    {
+      name: 'CLA',
+      description: 'Contributor License Agreement',
+      url: 'static/cla.html',
+    },
+  ];
+  const auth: ContributorAgreementInfo = {
+    name: 'Individual',
+    description: 'test-description',
+    url: 'static/cla_individual.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      options: {
+        visible_to_all: true,
+      },
+      group_id: '20',
+      owner: 'CLA Accepted - Individual',
+      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      created_on: '2017-07-31 15:11:04.000000000',
+      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0' as GroupId,
+      name: 'CLA Accepted - Individual' as GroupName,
+    },
+  };
+
+  const auth2: ContributorAgreementInfo = {
+    name: 'Individual2',
+    description: 'test-description2',
+    url: 'static/cla_individual2.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      options: {
+        visible_to_all: false,
+      },
+      group_id: '21',
+      owner: 'CLA Accepted - Individual2',
+      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      created_on: '2017-07-31 15:25:42.000000000',
+      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb' as GroupId,
+      name: 'CLA Accepted - Individual2' as GroupName,
+    },
+  };
+
+  const auth3: ContributorAgreementInfo = {
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla_individual.html',
+  };
+
+  const config: ServerInfo = {
+    ...createServerInfo(),
+    auth: {
+      auth_type: AuthType.HTTP,
+      editable_account_fields: [],
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual',
+          description: 'test-description',
+          url: 'static/cla_individual.html',
+        },
+        {
+          name: 'CLA',
+          description: 'Contributor License Agreement',
+          url: 'static/cla.html',
+        },
+      ],
+    },
+  };
+  const config2: ServerInfo = {
+    ...createServerInfo(),
+    auth: {
+      auth_type: AuthType.HTTP,
+      editable_account_fields: [],
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual2',
+          description: 'test-description2',
+          url: 'static/cla_individual2.html',
+        },
+      ],
+    },
+  };
+  const groups: GroupInfo[] = [
+    {
+      options: {visible_to_all: true},
+      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0' as GroupId,
+      group_id: '3',
+      name: 'CLA Accepted - Individual' as GroupName,
+    },
+  ];
+
+  setup(done => {
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
+    stubRestApi('getAccountAgreements').returns(
+      Promise.resolve(signedAgreements)
+    );
+    element = basicFixture.instantiate();
+    element.loadData().then(() => {
+      flush(done);
+    });
+  });
+
+  test('renders as expected with signed agreement', () => {
+    const agreementSections = queryAll(element, '.contributorAgreementButton');
+    const agreementSubmittedTexts = queryAll(element, '.alreadySubmittedText');
+    assert.equal(agreementSections.length, 2);
+    assert.isFalse(
+      queryAndAssert<HTMLInputElement>(agreementSections[0], 'input').disabled
+    );
+    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display, 'none');
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(agreementSections[1], 'input').disabled
+    );
+    assert.notEqual(
+      getComputedStyle(agreementSubmittedTexts[1]).display,
+      'none'
+    );
+  });
+
+  test('_disableAgreements', () => {
+    // In the auto verify group and have not yet signed agreement
+    assert.isTrue(element._disableAgreements(auth, groups, signedAgreements));
+    // Not in the auto verify group and have not yet signed agreement
+    assert.isFalse(element._disableAgreements(auth2, groups, signedAgreements));
+    // Not in the auto verify group, have signed agreement
+    assert.isTrue(element._disableAgreements(auth3, groups, signedAgreements));
+    // Make sure the undefined check works
+    assert.isFalse(
+      element._disableAgreements(auth, undefined, signedAgreements)
+    );
+  });
+
+  test('_hideAgreements', () => {
+    // Not in the auto verify group and have not yet signed agreement
+    assert.equal(element._hideAgreements(auth, groups, signedAgreements), '');
+    // In the auto verify group
+    assert.equal(
+      element._hideAgreements(auth2, groups, signedAgreements),
+      'hide'
+    );
+    // Not in the auto verify group, have signed agreement
+    assert.equal(element._hideAgreements(auth3, groups, signedAgreements), '');
+  });
+
+  test('_disableAgreementsText', () => {
+    assert.isFalse(element._disableAgreementsText('I AGREE'));
+    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+  });
+
+  test('_computeHideAgreementClass', () => {
+    assert.equal(
+      element._computeHideAgreementClass(
+        auth.name,
+        config.auth.contributor_agreements
+      ),
+      'hideAgreementsTextBox'
+    );
+    assert.isNotOk(
+      element._computeHideAgreementClass(
+        auth.name,
+        config2.auth.contributor_agreements
+      )
+    );
+  });
+
+  test('_getAgreementsUrl', () => {
+    assert.equal(
+      element._getAgreementsUrl('http://test.org/test.html'),
+      'http://test.org/test.html'
+    );
+    assert.equal(element._getAgreementsUrl('test_cla.html'), '/test_cla.html');
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 411d3aa..5d8cc51 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -18,7 +18,6 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-preferences_html';
@@ -37,9 +36,7 @@
   };
 }
 @customElement('gr-edit-preferences')
-export class GrEditPreferences extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditPreferences extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
deleted file mode 100644
index cffd1ae..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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-edit-preferences.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-edit-preferences');
-
-suite('gr-edit-preferences tests', () => {
-  let element;
-
-  let editPreferences;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(() => {
-    editPreferences = {
-      auto_close_brackets: false,
-      cursor_blink_rate: 0,
-      hide_line_numbers: false,
-      hide_top_menu: false,
-      indent_unit: 2,
-      indent_with_tabs: false,
-      key_map_type: 'DEFAULT',
-      line_length: 100,
-      line_wrapping: false,
-      match_brackets: true,
-      show_base: false,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-
-    stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
-
-    element = basicFixture.instantiate();
-
-    return element.loadData();
-  });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Tab width', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.tab_size);
-    assert.equal(valueOf('Columns', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.line_length);
-    assert.equal(valueOf('Indent unit', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.indent_unit);
-    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
-        .firstElementChild.checked, editPreferences.syntax_highlighting);
-    assert.equal(valueOf('Show tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.show_tabs);
-    assert.equal(valueOf('Match brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.match_brackets);
-    assert.equal(valueOf('Line wrapping', 'editPreferences')
-        .firstElementChild.checked, editPreferences.line_wrapping);
-    assert.equal(valueOf('Indent with tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.indent_with_tabs);
-    assert.equal(valueOf('Auto close brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.auto_close_brackets);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    stubRestApi('saveEditPreferences')
-        .returns(Promise.resolve());
-    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
-        .firstElementChild;
-    showTabsCheckbox.checked = false;
-    element._handleEditShowTabsChanged();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
new file mode 100644
index 0000000..bfdcaa0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
@@ -0,0 +1,125 @@
+/**
+ * @license
+ * Copyright (C) 2018 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-edit-preferences';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrEditPreferences} from './gr-edit-preferences';
+import {EditPreferencesInfo} from '../../../types/common';
+import {IronInputElement} from '@polymer/iron-input';
+
+const basicFixture = fixtureFromElement('gr-edit-preferences');
+
+suite('gr-edit-preferences tests', () => {
+  let element: GrEditPreferences;
+
+  let editPreferences: EditPreferencesInfo;
+
+  function valueOf(title: string, id: string): Element {
+    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl?.textContent?.trim() === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
+      }
+    }
+    assert.fail(`element with title ${title} not found`);
+  }
+
+  setup(async () => {
+    editPreferences = {
+      auto_close_brackets: false,
+      cursor_blink_rate: 0,
+      hide_line_numbers: false,
+      hide_top_menu: false,
+      indent_unit: 2,
+      indent_with_tabs: false,
+      key_map_type: 'DEFAULT',
+      line_length: 100,
+      line_wrapping: false,
+      match_brackets: true,
+      show_base: false,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
+
+    stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    const tabWidthInput = valueOf('Tab width', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(tabWidthInput.bindValue, `${editPreferences.tab_size}`);
+
+    const columnsInput = valueOf('Columns', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(columnsInput.bindValue, `${editPreferences.line_length}`);
+
+    const indentInput = valueOf('Indent unit', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(indentInput.bindValue, `${editPreferences.indent_unit}`);
+
+    const syntaxInput = valueOf('Syntax highlighting', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(syntaxInput.checked, editPreferences.syntax_highlighting);
+
+    const tabsInput = valueOf('Show tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(tabsInput.checked, editPreferences.show_tabs);
+
+    const bracketsInput = valueOf('Match brackets', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(bracketsInput.checked, editPreferences.match_brackets);
+
+    const wrappingInput = valueOf('Line wrapping', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(wrappingInput.checked, editPreferences.line_wrapping);
+
+    const indentTabsInput = valueOf('Indent with tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(indentTabsInput.checked, editPreferences.indent_with_tabs);
+
+    const autoCloseInput = valueOf('Auto close brackets', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(autoCloseInput.checked, editPreferences.auto_close_brackets);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', async () => {
+    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    showTabsCheckbox.checked = false;
+    element._handleEditShowTabsChanged();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 9960d83..925d9fb 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-email-editor_html';
@@ -27,9 +26,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-email-editor')
-export class GrEmailEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEmailEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index 0fd0ad9..9cce48e 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-gpg-editor_html';
@@ -46,9 +45,7 @@
   }
 }
 @customElement('gr-gpg-editor')
-export class GrGpgEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGpgEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index c69bebd..c24eaef 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-list_html';
@@ -31,9 +30,7 @@
   }
 }
 @customElement('gr-group-list')
-export class GrGroupList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroupList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 9bb4b00..0842b3a 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-http-password_html';
@@ -40,9 +39,7 @@
 }
 
 @customElement('gr-http-password')
-export class GrHttpPassword extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrHttpPassword extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -59,8 +56,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 837332a..221df1f 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -19,7 +19,6 @@
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-identities_html';
@@ -39,9 +38,7 @@
 }
 
 @customElement('gr-identities')
-export class GrIdentities extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrIdentities extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 805c9ca..34acaf9 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -20,7 +20,6 @@
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-menu-editor_html';
@@ -28,9 +27,7 @@
 import {TopMenuItemInfo} from '../../../types/common';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMenuEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index 85e692d..02ba85d 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -18,7 +18,6 @@
 import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-registration-dialog_html';
@@ -43,9 +42,7 @@
 }
 
 @customElement('gr-registration-dialog')
-export class GrRegistrationDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRegistrationDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
index 5d80a84d..887c441 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-item_html';
@@ -27,9 +26,7 @@
 }
 
 @customElement('gr-settings-item')
-class GrSettingsItem extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrSettingsItem extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
index e288d20..2f89b52 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/gr-page-nav-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-menu-item_html';
@@ -28,9 +27,7 @@
 }
 
 @customElement('gr-settings-menu-item')
-class GrSettingsMenuItem extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrSettingsMenuItem extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 809139d..35448c4 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
@@ -42,7 +42,6 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-view_html';
@@ -120,7 +119,7 @@
 
 @customElement('gr-settings-view')
 export class GrSettingsView extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -212,8 +211,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
     this.listen(window, 'location-change', '_handleLocationChange');
@@ -300,9 +299,9 @@
     });
   }
 
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.unlisten(window, 'location-change', '_handleLocationChange');
+    super.disconnectedCallback();
   }
 
   _handleLocationChange() {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 98abb3fb..79789bb 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -329,7 +329,7 @@
   test('emails are loaded without emailToken', () => {
     sinon.stub(element.$.emailEditor, 'loadData');
     element.params = {};
-    element.attached();
+    element.connectedCallback();
     assert.isTrue(element.$.emailEditor.loadData.calledOnce);
   });
 
@@ -465,7 +465,7 @@
       confirmEmailStub = stubRestApi('confirmEmail').returns(
           new Promise(resolve => { resolveConfirm = resolve; }));
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.attached();
+      element.connectedCallback();
     });
 
     test('it is used to confirm email via rest API', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index b30b2cb..b2373ec 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-ssh-editor_html';
@@ -46,9 +45,7 @@
   }
 }
 @customElement('gr-ssh-editor')
-export class GrSshEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSshEditor extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index a45e160..fe37795 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -20,7 +20,6 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-watched-projects-editor_html';
@@ -49,8 +48,8 @@
   };
 }
 @customElement('gr-watched-projects-editor')
-export class GrWatchedProjectsEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrWatchedProjectsEditor extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 8ff7a3a..e344a1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -18,7 +18,6 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-chip_html';
@@ -27,9 +26,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-account-chip')
-export class GrAccountChip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountChip extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 2562a1a..7c8c479 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-autocomplete/gr-autocomplete';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-entry_html';
@@ -33,9 +32,7 @@
  * and/or group with autocomplete support.
  */
 @customElement('gr-account-entry')
-export class GrAccountEntry extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountEntry extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 3f8fe6b..4702958 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-label_html';
@@ -34,9 +33,7 @@
 import {ShowAlertEventDetail} from '../../../types/events';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountLabel extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
index e25bdea..f37aa01 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -29,7 +29,6 @@
   }
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element._config = {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index be54f4e..5d72ca5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -17,7 +17,6 @@
 
 import '../gr-account-label/gr-account-label';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-link_html';
@@ -26,9 +25,7 @@
 import {AccountInfo, ChangeInfo} from '../../../types/common';
 
 @customElement('gr-account-link')
-class GrAccountLink extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrAccountLink extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
index af485c6..34fef2f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-link.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-link');
 
@@ -26,7 +25,6 @@
   let element;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 5cc1240..3b15233 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -17,7 +17,6 @@
 import '../gr-account-chip/gr-account-chip';
 import '../gr-account-entry/gr-account-entry';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-list_html';
@@ -115,9 +114,7 @@
 }
 
 @customElement('gr-account-list')
-export class GrAccountList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
index 6430290..20e8672 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
@@ -61,7 +60,6 @@
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     element.accounts = [existingAccount1, existingAccount2];
     suggestionsProvider = new MockSuggestionsProvider();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index a0fddcd..135abfc 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-alert_html';
@@ -31,9 +30,7 @@
 }
 
 @customElement('gr-alert')
-export class GrAlert extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAlert extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -75,21 +72,21 @@
   _actionCallback?: () => void;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this._boundTransitionEndHandler) {
       this.removeEventListener(
         'transitionend',
         this._boundTransitionEndHandler
       );
     }
+    super.disconnectedCallback();
   }
 
   show(text: string, actionText?: string, actionCallback?: () => void) {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
index b66a1dd..bc517a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -27,7 +27,7 @@
       bottom: 1.25rem;
       border-radius: var(--border-radius);
       box-shadow: var(--elevation-level-2);
-      color: var(--view-background-color);
+      color: var(--tooltip-text-color);
       left: 1.25rem;
       position: fixed;
       transform: translateY(5rem);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index aff6d50..3e85ed7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -18,7 +18,6 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete-dropdown_html';
@@ -55,9 +54,7 @@
  */
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends IronFitMixin(
-  KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))
-  ),
+  KeyboardShortcutMixin(LegacyElementMixin(PolymerElement)),
   IronFitBehavior as IronFitBehavior
 ) {
   static get template() {
@@ -104,6 +101,12 @@
     };
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   close() {
     this.isHidden = true;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
index d3d2481..7e00e47 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
@@ -93,7 +93,7 @@
   </div>
   <gr-cursor-manager
     id="cursor"
-    index="{{index}}"
+    index="[[index]]"
     cursor-target-class="selected"
     scroll-mode="never"
     focus-on-move=""
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index f4eb053..f34c3ad 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -20,7 +20,6 @@
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete_html';
@@ -72,7 +71,7 @@
 
 @customElement('gr-autocomplete')
 export class GrAutocomplete extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -210,16 +209,16 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.listen(document.body, 'click', '_handleBodyClick');
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.unlisten(document.body, 'click', '_handleBodyClick');
     this.cancelDebouncer(DEBOUNCER_UPDATE_SUGGESTIONS);
+    super.disconnectedCallback();
   }
 
   get focusStart() {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 9d7e19b..b628125 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-avatar_html';
@@ -26,9 +25,7 @@
 import {appContext} from '../../../services/app-context';
 
 @customElement('gr-avatar')
-export class GrAvatar extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAvatar extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -45,8 +42,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     Promise.all([
       this._getConfig(),
       getPluginLoader().awaitPluginsLoaded(),
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 60b891e..72b2744 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -16,7 +16,6 @@
  */
 import '@polymer/paper-button/paper-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property, computed, observe} from '@polymer/decorators';
@@ -36,7 +35,7 @@
 
 @customElement('gr-button')
 export class GrButton extends LegacyElementMixin(
-  KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(PolymerElement)))
+  KeyboardShortcutMixin(TooltipMixin(PolymerElement))
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
similarity index 72%
rename from polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
rename to polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 242cb28..86ae2d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -15,18 +15,21 @@
  * limitations under the License.
  */
 
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma.js';
-import './gr-button.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
 import {appContext} from '../../../services/app-context.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {GrButton} from './gr-button.js';
+import {queryAndAssert} from '../../../test/test-utils.js';
+import {PaperButtonElement} from '@polymer/paper-button';
 
 const basicFixture = fixtureFromElement('gr-button');
 
 const nestedFixture = fixtureFromTemplate(html`
-<div id="test">
-  <gr-button class="testBtn"></gr-button>
-</div>
+  <div id="test">
+    <gr-button class="testBtn"></gr-button>
+  </div>
 `);
 
 const tabindexFixture = fixtureFromTemplate(html`
@@ -34,11 +37,11 @@
 `);
 
 suite('gr-button tests', () => {
-  let element;
+  let element: GrButton;
 
-  const addSpyOn = function(eventName) {
+  const addSpyOn = function (eventName: string) {
     const spy = sinon.spy();
-    if (eventName == 'tap') {
+    if (eventName === 'tap') {
       addListener(element, eventName, spy);
     } else {
       element.addEventListener(eventName, spy);
@@ -51,7 +54,10 @@
   });
 
   test('disabled is set by disabled', () => {
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    const paperBtn = queryAndAssert<PaperButtonElement>(
+      element,
+      'paper-button'
+    );
     assert.isFalse(paperBtn.disabled);
     element.disabled = true;
     assert.isTrue(paperBtn.disabled);
@@ -60,17 +66,21 @@
   });
 
   test('loading set from listener', () => {
-    let resolve;
+    let resolve: Function;
     element.addEventListener('click', e => {
-      e.target.loading = true;
-      resolve = () => e.target.loading = false;
+      const target = e.target as HTMLElement;
+      target.setAttribute('loading', 'true');
+      resolve = () => target.removeAttribute('loading');
     });
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    const paperBtn = queryAndAssert<PaperButtonElement>(
+      element,
+      'paper-button'
+    );
     assert.isFalse(paperBtn.disabled);
     MockInteractions.tap(element);
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
-    resolve();
+    resolve!();
     flush();
     assert.isFalse(paperBtn.disabled);
     assert.isFalse(element.hasAttribute('loading'));
@@ -92,13 +102,13 @@
   });
 
   test('tabindex should be preserved', () => {
-    element = tabindexFixture.instantiate();
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
-    element.disabled = true;
-    assert.equal(element.getAttribute('tabindex'), '-1');
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
+    const tabIndexElement = tabindexFixture.instantiate() as GrButton;
+    tabIndexElement.disabled = false;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
+    tabIndexElement.disabled = true;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '-1');
+    tabIndexElement.disabled = false;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
   });
 
   // 'tap' event is tested so we don't loose backward compatibility with older
@@ -123,14 +133,14 @@
 
   // Keycodes: 32 for Space, 13 for Enter.
   for (const key of [32, 13]) {
-    test('dispatches click event on keycode ' + key, () => {
+    test(`dispatches click event on keycode ${key}`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
       MockInteractions.pressAndReleaseKeyOn(element, key);
       assert.isTrue(tapSpy.calledOnce);
     });
 
-    test('dispatches no click event with modifier on keycode ' + key, () => {
+    test(`dispatches no click event with modifier on keycode ${key}`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
       MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
@@ -156,7 +166,7 @@
 
     // Keycodes: 32 for Space, 13 for Enter.
     for (const key of [32, 13]) {
-      test('stops click event on keycode ' + key, () => {
+      test(`stops click event on keycode ${key}`, () => {
         const tapSpy = sinon.spy();
         element.addEventListener('click', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
@@ -166,10 +176,9 @@
   });
 
   suite('reporting', () => {
-    let reportStub;
+    let reportStub: sinon.SinonStub;
     setup(() => {
-      reportStub = sinon.stub(appContext.reportingService,
-          'reportInteraction');
+      reportStub = sinon.stub(appContext.reportingService, 'reportInteraction');
       reportStub.reset();
     });
 
@@ -178,20 +187,20 @@
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${basicFixture.fixtureId}>gr-button`,
+        path: `html>body>test-fixture#${element.parentElement!.id}>gr-button`,
       });
     });
 
     test('report event after click on nested', () => {
-      element = nestedFixture.instantiate();
-      MockInteractions.click(element.querySelector('gr-button'));
+      const nestedElement = nestedFixture.instantiate() as HTMLDivElement;
+      MockInteractions.click(queryAndAssert(nestedElement, 'gr-button'));
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${nestedFixture.fixtureId}` +
-            `>div#test>gr-button.testBtn`,
+        path:
+          `html>body>test-fixture#${nestedElement.parentElement!.id}` +
+          '>div#test>gr-button.testBtn',
       });
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 1ecaf7f..bafc00f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -16,13 +16,13 @@
  */
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-star_html';
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {fireAlert} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -37,7 +37,7 @@
 
 @customElement('gr-change-star')
 export class GrChangeStar extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -78,6 +78,7 @@
       change: this.change,
       starred: newVal,
     };
+    if (newVal) fireAlert(this, 'Starring change...');
     this.dispatchEvent(
       new CustomEvent('toggle-star', {
         bubbles: true,
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 7cf9bb1..c84344e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-status_html';
@@ -46,9 +45,7 @@
 
 /** @extends PolymerElement */
 @customElement('gr-change-status')
-class GrChangeStatus extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrChangeStatus extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 a5b7df7..06144dc 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
@@ -19,7 +19,6 @@
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-thread_html';
@@ -61,6 +60,8 @@
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
 import {check, assertIsDefined} from '../../../utils/common-util';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -74,7 +75,7 @@
 
 @customElement('gr-comment-thread')
 export class GrCommentThread extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   // KeyboardShortcutMixin Not used in this element rather other elements tests
 
@@ -182,6 +183,7 @@
   _renderPrefs: RenderPreferences = {
     hide_left_side: true,
     disable_context_control_buttons: true,
+    show_file_comment_button: false,
   };
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -211,6 +213,8 @@
 
   readonly storage = new GrStorage();
 
+  private readonly syntaxLayer = new GrSyntaxLayer();
+
   private isCommentContextExperimentEnabled = this.flagsService.isEnabled(
     KnownExperimentId.COMMENT_CONTEXT
   );
@@ -226,8 +230,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
@@ -235,7 +239,6 @@
       if (!prefs) return;
       this._prefs = {
         ...prefs,
-        show_file_comment_button: false,
         // override explicitly so that diff doesn't take too much width
         // compared to the context
         line_wrapping: false,
@@ -248,7 +251,16 @@
   get _diff() {
     if (this.comments === undefined || this.path === undefined) return;
     if (!this.comments[0]?.context_lines?.length) return;
-    return computeDiffFromContext(this.comments[0].context_lines, this.path);
+    waitForEventOnce(this, 'render').then(() => {
+      this.syntaxLayer.process();
+    });
+    const diff = computeDiffFromContext(
+      this.comments[0].context_lines,
+      this.path,
+      this.comments[0].source_content_type
+    );
+    this.syntaxLayer.init(diff);
+    return diff;
   }
 
   _shouldShowCommentContext(diff?: DiffInfo) {
@@ -333,6 +345,11 @@
     return undefined;
   }
 
+  _getLayers(diff?: DiffInfo) {
+    if (!diff) return [];
+    return [this.syntaxLayer];
+  }
+
   _getUrlForViewDiff(comments: UIComment[]) {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.projectName, 'projectName');
@@ -504,7 +521,7 @@
 
     if (!isEditing) {
       // Allow the reply to render in the dom-repeat.
-      this.async(() => {
+      setTimeout(() => {
         const commentEl = this._commentElWithDraftID(reply.__draftID);
         if (commentEl) commentEl.save();
       }, 1);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 55408c0..60df799 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -86,15 +86,21 @@
     .fileName {
       padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
     }
+    @media only screen and (max-width: 1200px) {
+      .diff-container {
+        display: none;
+      }
+    }
     .diff-container {
       margin-left: var(--spacing-l);
       border: 1px solid var(--border-color);
     }
-    .view-diff-container {
-      text-align: end;
-    }
     .view-diff-button {
-      margin: var(--spacing-m);
+      margin: var(--spacing-s) var(--spacing-m);
+    }
+    .view-diff-container {
+      border-top: 1px solid var(--border-color);
+      background-color: var(--background-color-primary);
     }
   </style>
 
@@ -193,10 +199,12 @@
           id="diff"
           change-num="[[changeNum]]"
           diff="[[_diff]]"
+          layers="[[_getLayers(_diff)]]"
           path="[[path]]"
           prefs="[[_prefs]]"
           render-prefs="[[_renderPrefs]]"
           highlight-range="[[getHighlightRange(comments)]]"
+          on-render="_handleDiffRender"
         >
         </gr-diff>
         <div class="view-diff-container">
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 bf376f6..2807730 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -30,7 +30,6 @@
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment_html';
@@ -48,6 +47,7 @@
   ConfigInfo,
   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';
@@ -109,7 +109,7 @@
 
 @customElement('gr-comment')
 export class GrComment extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -282,8 +282,8 @@
   reporting = appContext.reportingService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
@@ -298,14 +298,14 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_FIRE_UPDATE);
     this.cancelDebouncer(DEBOUNCER_STORE);
     this.cancelDebouncer(DEBOUNCER_DRAFT_TOAST);
     if (this.textarea) {
       this.textarea.closeDropdown();
     }
+    super.disconnectedCallback();
   }
 
   _getAuthor(comment: UIComment) {
@@ -587,7 +587,7 @@
       this._fireUpdate();
     }
     if (editing) {
-      this.async(() => {
+      setTimeout(() => {
         flush();
         this.textarea && this.textarea.putCursorAtEnd();
       }, 1);
@@ -930,7 +930,7 @@
 
   _getPatchNum(): PatchSetNum {
     const patchNum = this.isOnParent()
-      ? ('PARENT' as PatchSetNum)
+      ? ('PARENT' as BasePatchSetNum)
       : this.patchNum;
     if (patchNum === undefined) throw new Error('patchNum undefined');
     return patchNum;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 6c925b0..f28a7fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -442,7 +442,6 @@
 
     setup(() => {
       stubRestApi('getAccount').returns(Promise.resolve(null));
-      stubRestApi('getConfig').returns(Promise.resolve({}));
       stubRestApi('saveDiffDraft').returns(Promise.resolve({
         ok: true,
         text() {
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index a636a07..6a350b0 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
@@ -35,8 +34,8 @@
 }
 
 @customElement('gr-confirm-delete-comment-dialog')
-export class GrConfirmDeleteCommentDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
+export class GrConfirmDeleteCommentDialog extends LegacyElementMixin(
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 47dacc3..b47925b 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -19,7 +19,6 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-copy-clipboard_html';
@@ -41,9 +40,7 @@
 
 /** @extends PolymerElement */
 @customElement('gr-copy-clipboard')
-export class GrCopyClipboard extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCopyClipboard extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -87,7 +84,7 @@
       this.$.input.style.display = 'none';
     }
     this.$.icon.icon = 'gr-icons:check';
-    this.async(
+    setTimeout(
       () => (this.$.icon.icon = 'gr-icons:content-copy'),
       COPY_TIMEOUT_MS
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 119ed20..411dac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-cursor-manager_html';
@@ -62,9 +61,7 @@
 }
 
 @customElement('gr-cursor-manager')
-export class GrCursorManager extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCursorManager extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -81,7 +78,7 @@
   /**
    * The index of the current target (if any). -1 otherwise.
    */
-  @property({type: Number, notify: true})
+  @property({type: Number})
   index = -1;
 
   /**
@@ -115,12 +112,6 @@
     return this.stops.filter(isTargetable);
   }
 
-  /** @override */
-  detached() {
-    super.detached();
-    this.unsetCursor();
-  }
-
   /**
    * Move the cursor forward. Clipped to the ends of the stop list.
    *
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 5bb4f4c..f53d87f 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-date-formatter_html';
@@ -79,7 +78,7 @@
 
 @customElement('gr-date-formatter')
 export class GrDateFormatter extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -134,8 +133,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index fa6403a..62a2b82 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dialog_html';
@@ -36,9 +35,7 @@
 }
 
 @customElement('gr-dialog')
-export class GrDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDialog extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
similarity index 74%
rename from polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
rename to polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 1238168..e7b7130 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-dialog.js';
-import {isHidden} from '../../../test/test-utils.js';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import {GrDialog} from './gr-dialog';
+import {isHidden, queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-dialog');
 
 suite('gr-dialog tests', () => {
-  let element;
+  let element: GrDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -34,12 +35,10 @@
     element.addEventListener('confirm', confirm);
     element.addEventListener('cancel', cancel);
 
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button[primary]'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
     assert.equal(confirm.callCount, 1);
 
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button:not([primary])'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button:not([primary])'));
     assert.equal(cancel.callCount, 1);
   });
 
@@ -48,7 +47,11 @@
     const handleConfirmStub = sinon.stub(element, '_handleConfirm');
     const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
     MockInteractions.pressAndReleaseKeyOn(
-        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+      queryAndAssert(element, 'main'),
+      13,
+      null,
+      'enter'
+    );
     flush();
 
     assert.isTrue(handleKeydownSpy.called);
@@ -56,7 +59,11 @@
 
     element.confirmOnEnter = true;
     MockInteractions.pressAndReleaseKeyOn(
-        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+      queryAndAssert(element, 'main'),
+      13,
+      null,
+      'enter'
+    );
     flush();
 
     assert.isTrue(handleConfirmStub.called);
@@ -81,11 +88,11 @@
   });
 
   test('empty cancel label hides cancel btn', () => {
-    assert.isFalse(isHidden(element.$.cancel));
+    const cancelButton = queryAndAssert(element, '#cancel');
+    assert.isFalse(isHidden(cancelButton));
     element.cancelLabel = '';
     flush();
 
-    assert.isTrue(isHidden(element.$.cancel));
+    assert.isTrue(isHidden(cancelButton));
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 5810222..5664e22 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences_html';
@@ -40,9 +39,7 @@
 }
 
 @customElement('gr-diff-preferences')
-export class GrDiffPreferences extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffPreferences extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 4c2a417..f00d3bb 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,7 +17,6 @@
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-commands_html';
@@ -43,9 +42,7 @@
 }
 
 @customElement('gr-download-commands')
-export class GrDownloadCommands extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDownloadCommands extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -66,8 +63,8 @@
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 888f34f..28336cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -22,7 +22,6 @@
 import '../gr-date-formatter/gr-date-formatter';
 import '../gr-select/gr-select';
 import '../gr-file-status-chip/gr-file-status-chip';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown-list_html';
@@ -64,9 +63,7 @@
 export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
 
 @customElement('gr-dropdown-list')
-export class GrDropdownList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDropdownList extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -103,7 +100,7 @@
   _handleDropdownClick() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
-    this.async(() => {
+    setTimeout(() => {
       this.$.dropdown.close();
     }, 1);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
index c2c8d68..909a3bbf 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-dropdown-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dropdown-list');
 
@@ -25,7 +24,6 @@
   let element;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index ae9bfd7..29c6bd8 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -20,7 +20,6 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown_html';
@@ -67,7 +66,7 @@
 
 @customElement('gr-dropdown')
 export class GrDropdown extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -129,6 +128,12 @@
     };
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.$.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   /**
    * Handle the up key.
    */
@@ -220,7 +225,7 @@
   _close() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
-    this.async(() => {
+    setTimeout(() => {
       this.$.dropdown.close();
     }, 1);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
index 02bffe4..fea4a08 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dropdown.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dropdown');
 
@@ -26,7 +25,6 @@
   let element;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index c744eab..c849fac 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -19,7 +19,6 @@
 import '../gr-storage/gr-storage';
 import '../gr-button/gr-button';
 import {GrStorage} from '../gr-storage/gr-storage';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
@@ -40,9 +39,7 @@
 const DEBOUNCER_STORE = 'store';
 
 @customElement('gr-editable-content')
-export class GrEditableContent extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditableContent extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -120,6 +117,8 @@
 
   private readonly flagsService = appContext.flagsService;
 
+  private readonly reporting = appContext.reportingService;
+
   /** @override */
   ready() {
     super.ready();
@@ -129,8 +128,9 @@
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_STORE);
+    super.disconnectedCallback();
   }
 
   _contentChanged() {
@@ -238,6 +238,10 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'Commit message',
+      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+    });
     if (this._commitCollapsed) {
       window.scrollTo(0, 0);
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 0f530bf..68b8121 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -62,7 +62,6 @@
       background-color: var(--view-background-color);
       display: flex;
       justify-content: flex-end;
-      margin-bottom: 8px;
       border-top-width: 1px;
       border-top-style: solid;
       border-radius: 0 0 4px 4px;
@@ -88,6 +87,11 @@
     .save-button {
       margin-right: var(--spacing-xs);
     }
+    gr-button {
+      font-family: var(--font-family);
+      line-height: var(--line-height-normal);
+      padding: var(--spacing-xs);
+    }
   </style>
   <div
     class$="viewer new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 6f0e84a..db70e3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -18,7 +18,7 @@
 import '@polymer/paper-input/paper-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import '../../shared/gr-autocomplete/gr-autocomplete';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -28,6 +28,10 @@
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PaperInputElementExt} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {
+  AutocompleteQuery,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -40,14 +44,13 @@
 
 export interface GrEditableLabel {
   $: {
-    input: PaperInputElementExt;
     dropdown: IronDropdownElement;
   };
 }
 
 @customElement('gr-editable-label')
 export class GrEditableLabel extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -92,6 +95,12 @@
   @property({type: Boolean})
   showAsEditPencil = false;
 
+  @property({type: Boolean})
+  autocomplete = false;
+
+  @property({type: Object})
+  query?: AutocompleteQuery;
+
   /** @override */
   ready() {
     super.ready();
@@ -120,8 +129,9 @@
     if (this.readOnly || this.editing) return;
     return this._open().then(() => {
       this._nativeInput.focus();
-      if (!this.$.input.value) return;
-      this._nativeInput.setSelectionRange(0, this.$.input.value.length);
+      const input = this.getInput();
+      if (!input?.value) return;
+      this._nativeInput.setSelectionRange(0, input.value.length);
     });
   }
 
@@ -133,7 +143,7 @@
 
   _open() {
     this.$.dropdown.open();
-    this._inputText = this.value;
+    this._inputText = this.value || '';
     this.editing = true;
 
     return new Promise<void>(resolve => {
@@ -148,7 +158,7 @@
   _awaitOpen(fn: () => void) {
     let iters = 0;
     const step = () => {
-      this.async(() => {
+      setTimeout(() => {
         if (this.$.dropdown.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
@@ -190,8 +200,9 @@
 
   get _nativeInput(): HTMLInputElement {
     // In Polymer 2 inputElement isn't nativeInput anymore
-    return (this.$.input.$.nativeInput ||
-      this.$.input.inputElement) as HTMLInputElement;
+    return (this.getInput()?.$.nativeInput ||
+      this.getInput()?.inputElement ||
+      this.getGrAutocomplete()) as HTMLInputElement;
   }
 
   _handleEnter(e: CustomKeyboardEvent) {
@@ -212,6 +223,10 @@
     }
   }
 
+  _handleCommit() {
+    this._save();
+  }
+
   _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
     const classes = [];
     if (!readOnly) {
@@ -226,4 +241,12 @@
   _updateTitle(value?: string) {
     this.setAttribute('title', this._computeLabel(value, this.placeholder));
   }
+
+  getInput(): PaperInputElementExt | null {
+    return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
+  }
+
+  getGrAutocomplete(): GrAutocomplete | null {
+    return this.shadowRoot!.querySelector<GrAutocomplete>('#autocomplete');
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
index ad4fead..d7b6df8 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
@@ -105,12 +105,24 @@
   >
     <div class="dropdown-content" slot="dropdown-content">
       <div class="inputContainer">
-        <paper-input
-          id="input"
-          label="[[labelText]]"
-          maxlength="[[maxLength]]"
-          value="{{_inputText}}"
-        ></paper-input>
+        <template is="dom-if" if="[[!autocomplete]]">
+          <paper-input
+            id="input"
+            label="[[labelText]]"
+            maxlength="[[maxLength]]"
+            value="{{_inputText}}"
+          ></paper-input>
+        </template>
+        <template is="dom-if" if="[[autocomplete]]">
+          <gr-autocomplete
+            label="[[labelText]]"
+            id="autocomplete"
+            text="{{_inputText}}"
+            query="[[query]]"
+            on-commit="_handleCommit"
+          >
+          </gr-autocomplete>
+        </template>
         <div class="buttons">
           <gr-button link="" id="cancelBtn" on-click="_cancel"
             >cancel</gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index 2bdc570..3e217f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -50,7 +50,8 @@
 
     await flush();
     // In Polymer 2 inputElement isn't nativeInput anymore
-    input = element.$.input.$.nativeInput || element.$.input.inputElement;
+    const paperInput = element.shadowRoot.querySelector('#input');
+    input = paperInput.$.nativeInput || paperInput.inputElement;
   });
 
   test('element render', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
index 9298fa3..0e9c0fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-file-status-chip_html';
 import {customElement, property} from '@polymer/decorators';
@@ -35,9 +34,7 @@
 };
 
 @customElement('gr-file-status-chip')
-export class GrFileStatusChip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrFileStatusChip extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index a6c7b92..82ed264 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-linked-text/gr-linked-text';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
@@ -45,9 +44,7 @@
 }
 
 @customElement('gr-formatted-text')
-export class GrFormattedText extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrFormattedText extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index ddae8ea..9cbd11c 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -20,7 +20,6 @@
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
 import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-account_html';
@@ -48,8 +47,8 @@
 import {assertIsDefined} from '../../../utils/common-util';
 
 @customElement('gr-hovercard-account')
-export class GrHovercardAccount extends GestureEventListeners(
-  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+export class GrHovercardAccount extends hovercardBehaviorMixin(
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -96,8 +95,8 @@
     this.reporting = appContext.reportingService;
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index b8f0161..74c47d20 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -119,8 +119,8 @@
       private isScheduledToHide?: boolean;
 
       /** @override */
-      attached() {
-        super.attached();
+      connectedCallback() {
+        super.connectedCallback();
         if (!this._target) {
           this._target = this.target;
         }
@@ -141,11 +141,11 @@
         this.listen(this, 'mouseleave', 'unlock');
       }
 
-      detached() {
-        super.detached();
+      disconnectedCallback() {
         this.cancelShowDebouncer();
         this.cancelHideDebouncer();
         this.unlock();
+        super.disconnectedCallback();
       }
 
       /** @override */
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
index c56bc8f..a5f23ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -16,7 +16,6 @@
  */
 
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard_html';
 import {hovercardBehaviorMixin} from './gr-hovercard-behavior';
@@ -25,8 +24,8 @@
 import {customElement} from '@polymer/decorators';
 
 @customElement('gr-hovercard')
-export class GrHovercard extends GestureEventListeners(
-  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+export class GrHovercard extends hovercardBehaviorMixin(
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 7745da8..8803557 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -63,8 +63,6 @@
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=mode_comment&style=outline-->
-      <g id="comment-outline"><path d="M0 0h24v24H0V0z" fill="none"></path><path d="M20 17.17L18.83 16H4V4h16v13.17zM20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
       <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
@@ -130,6 +128,10 @@
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
       <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_down-->
+      <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_up-->
+      <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
     </defs>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index a3d038d..857d079 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -41,10 +41,12 @@
   private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
     plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
   setLayer(annotationCallback: AnnotationCallback) {
+    this.reporting.trackApi(this.plugin, 'annotation', 'setLayer');
     if (this.annotationCallback) {
       console.warn('Overwriting an existing plugin annotation layer.');
     }
@@ -55,6 +57,7 @@
   setCoverageProvider(
     coverageProvider: CoverageProvider
   ): GrAnnotationActionsInterface {
+    this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
       console.warn('Overwriting an existing coverage provider.');
     }
@@ -74,6 +77,7 @@
     checkboxLabel: string,
     onAttached: (checkboxEl: Element | null) => void
   ) {
+    this.reporting.trackApi(this.plugin, 'annotation', 'enableToggleCheckbox');
     this.plugin.hook('annotation-toggler').onAttached(element => {
       if (!element.content) {
         this.reporting.error(new Error('plugin endpoint without content.'));
@@ -104,6 +108,7 @@
   }
 
   notify(path: string, start: number, end: number, side: Side) {
+    this.reporting.trackApi(this.plugin, 'annotation', 'notify');
     for (const annotationLayer of this.annotationLayers) {
       // Notify only the annotation layer that is associated with the specified
       // path.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index a4c6974..15d4680 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -68,7 +68,10 @@
 
   ActionType = ActionType;
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+    this.reporting.trackApi(this.plugin, 'actions', 'constructor');
     this.setEl(el);
   }
 
@@ -100,6 +103,7 @@
   }
 
   addPrimaryActionKey(key: PrimaryActionKey) {
+    this.reporting.trackApi(this.plugin, 'actions', 'addPrimaryActionKey');
     const el = this.ensureEl();
     if (el.primaryActionKeys.includes(key)) {
       return;
@@ -109,63 +113,77 @@
   }
 
   removePrimaryActionKey(key: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'removePrimaryActionKey');
     const el = this.ensureEl();
     el.primaryActionKeys = el.primaryActionKeys.filter(k => k !== key);
   }
 
   hideQuickApproveAction() {
+    this.reporting.trackApi(this.plugin, 'actions', 'hideQuickApproveAction');
     this.ensureEl().hideQuickApproveAction();
   }
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionOverflow');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionOverflow(type, key, overflow);
   }
 
   setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionPriority');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionPriority(type, key, priority);
   }
 
   setActionHidden(type: ActionType, key: string, hidden: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionHidden');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionHidden(type, key, hidden);
   }
 
   add(type: ActionType, label: string): string {
+    this.reporting.trackApi(this.plugin, 'actions', 'add');
     return this.ensureEl().addActionButton(type, label);
   }
 
   remove(key: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'remove');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().removeActionButton(key);
   }
 
   addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.reporting.trackApi(this.plugin, 'actions', 'addTapListener');
     this.ensureEl().addEventListener(key + '-tap', handler);
   }
 
   removeTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.reporting.trackApi(this.plugin, 'actions', 'removeTapListener');
     this.ensureEl().removeEventListener(key + '-tap', handler);
   }
 
   setLabel(key: string, text: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setLabel');
     this.ensureEl().setActionButtonProp(key, 'label', text);
   }
 
   setTitle(key: string, text: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setTitle');
     this.ensureEl().setActionButtonProp(key, 'title', text);
   }
 
   setEnabled(key: string, enabled: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setEnabled');
     this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
   }
 
   setIcon(key: string, icon: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setIcon');
     this.ensureEl().setActionButtonProp(key, 'icon', icon);
   }
 
   getActionDetails(action: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'getActionDetails');
     const el = this.ensureEl();
     return (
       el.getActionDetails(action) ||
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index effebe1..de57794 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -25,15 +25,20 @@
   ReplyChangedCallback,
   ValueChangedDetail,
 } from '../../../api/change-reply';
+import {appContext} from '../../../services/app-context';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
 export class GrChangeReplyInterface implements ChangeReplyPluginApi {
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     readonly plugin: PluginApi,
     readonly sharedApiElement: JsApiService
-  ) {}
+  ) {
+    this.reporting.trackApi(this.plugin, 'reply', 'constructor');
+  }
 
   get _el(): GrReplyDialog {
     return (this.sharedApiElement.getElement(
@@ -42,18 +47,22 @@
   }
 
   getLabelValue(label: string): string {
+    this.reporting.trackApi(this.plugin, 'reply', 'getLabelValue');
     return this._el.getLabelValue(label);
   }
 
   setLabelValue(label: string, value: string) {
+    this.reporting.trackApi(this.plugin, 'reply', 'setLabelValue');
     this._el.setLabelValue(label, value);
   }
 
   send(includeComments?: boolean) {
+    this.reporting.trackApi(this.plugin, 'reply', 'send');
     this._el.send(includeComments);
   }
 
   addReplyTextChangedCallback(handler: ReplyChangedCallback) {
+    this.reporting.trackApi(this.plugin, 'reply', 'addReplyTextChangedCb');
     const hookApi = this.plugin.hook('reply-text');
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<ValueChangedDetail>;
@@ -74,6 +83,7 @@
   }
 
   addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
+    this.reporting.trackApi(this.plugin, 'reply', 'addLabelValuesChangedCb');
     const hookApi = this.plugin.hook('reply-label-scores');
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<LabelsChangedDetail>;
@@ -95,6 +105,7 @@
   }
 
   showMessage(message: string) {
+    this.reporting.trackApi(this.plugin, 'reply', 'showMessage');
     this._el.setPluginMessage(message);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 52efc56..d6d4ce5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -31,7 +31,6 @@
   let plugin;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 82df2fa..46e91d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -19,6 +19,8 @@
 import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
 import {HookApi} from '../../../api/hook';
+import {appContext} from '../../../services/app-context';
+import {Execution} from '../../../constants/reporting';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
@@ -51,6 +53,8 @@
 
   private readonly _importedUrls = new Set<string>();
 
+  private readonly reporting = appContext.reportingService;
+
   private pluginLoaded = false;
 
   setPluginsReady() {
@@ -181,6 +185,10 @@
   }
 
   importUrl(pluginUrl: URL) {
+    this.reporting.reportExecution(Execution.METHOD_USED, {
+      id: 'import-href-endpoints',
+      pluginUrl,
+    });
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     let timerId: any;
     return Promise.race([
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index db34e5a..550a35f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -28,6 +28,7 @@
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {Execution} from '../../../constants/reporting';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -340,7 +341,10 @@
     const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
     const urlWithoutAP = this._urlFor(pluginUrl);
     let onerror = undefined;
-    this._getReporting().reportExecution('html-plugin', {pluginUrl});
+    this._getReporting().reportExecution(Execution.METHOD_USED, {
+      id: 'html-plugin',
+      pluginUrl,
+    });
     if (urlWithAP !== urlWithoutAP) {
       onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
     }
@@ -354,6 +358,10 @@
       };
     }
 
+    this._getReporting().reportExecution(Execution.METHOD_USED, {
+      id: 'import-href-loader',
+      url,
+    });
     importHref(url, () => {}, onerror, !sync);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index cd35d4e..150c45a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -18,6 +18,7 @@
 import {RequestPayload} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback, RestPluginApi} from '../../../api/rest';
+import {PluginApi} from '../../../api/plugin';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
@@ -36,33 +37,44 @@
 export class GrPluginRestApi implements RestPluginApi {
   private readonly restApi = appContext.restApiService;
 
-  constructor(private readonly prefix = '') {}
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi, private readonly prefix = '') {
+    this.reporting.trackApi(this.plugin, 'rest', 'constructor');
+  }
 
   getLoggedIn() {
+    this.reporting.trackApi(this.plugin, 'rest', 'getLoggedIn');
     return this.restApi.getLoggedIn();
   }
 
   getVersion() {
+    this.reporting.trackApi(this.plugin, 'rest', 'getVersion');
     return this.restApi.getVersion();
   }
 
   getConfig() {
+    this.reporting.trackApi(this.plugin, 'rest', 'getConfig');
     return this.restApi.getConfig();
   }
 
   invalidateReposCache() {
+    this.reporting.trackApi(this.plugin, 'rest', 'invalidateReposCache');
     this.restApi.invalidateReposCache();
   }
 
   getAccount() {
+    this.reporting.trackApi(this.plugin, 'rest', 'getAccount');
     return this.restApi.getAccount();
   }
 
   getAccountCapabilities(capabilities: string[]) {
+    this.reporting.trackApi(this.plugin, 'rest', 'getAccountCapabilities');
     return this.restApi.getAccountCapabilities(capabilities);
   }
 
   getRepos(filter: string, reposPerPage: number, offset?: number) {
+    this.reporting.trackApi(this.plugin, 'rest', 'getRepos');
     return this.restApi.getRepos(filter, reposPerPage, offset);
   }
 
@@ -100,6 +112,7 @@
     errFn?: ErrorCallback,
     contentType?: string
   ): Promise<Response | void> {
+    this.reporting.trackApi(this.plugin, 'rest', 'fetch');
     return this.restApi.send(
       method,
       this.prefix + url,
@@ -119,6 +132,7 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
+    this.reporting.trackApi(this.plugin, 'rest', 'send');
     // Plugins typically don't want Gerrit to show error dialogs for failed
     // requests. So we are defining a default errFn here, even if it is not
     // explicitly set by the caller.
@@ -167,6 +181,7 @@
   }
 
   get(url: string) {
+    this.reporting.trackApi(this.plugin, 'rest', 'get');
     return this.send(HttpMethod.GET, url);
   }
 
@@ -176,6 +191,7 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
+    this.reporting.trackApi(this.plugin, 'rest', 'post');
     return this.send(HttpMethod.POST, url, payload, errFn, contentType);
   }
 
@@ -185,10 +201,12 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
+    this.reporting.trackApi(this.plugin, 'rest', 'put');
     return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
   }
 
   delete(url: string) {
+    this.reporting.trackApi(this.plugin, 'rest', 'delete');
     return this.fetch(HttpMethod.DELETE, url).then(response => {
       if (response.status !== 204) {
         return response.text().then(text => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index e7843af..68fb96b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -54,6 +54,7 @@
 import {ChangeReplyPluginApi} from '../../../api/change-reply';
 import {RestPluginApi} from '../../../api/rest';
 import {HookApi, RegisterOptions} from '../../../api/hook';
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -84,6 +85,8 @@
 
   private readonly jsApi = appContext.jsApiService;
 
+  private readonly report = appContext.reportingService;
+
   constructor(url?: string) {
     this.domHooks = new GrDomHooksManager(this);
 
@@ -97,6 +100,7 @@
 
     this._url = new URL(url);
     this._name = getPluginNameFromUrl(this._url) ?? 'NULL';
+    this.report.trackApi(this, 'plugin', 'constructor');
   }
 
   getPluginName() {
@@ -104,6 +108,7 @@
   }
 
   registerStyleModule(endpoint: string, moduleName: string) {
+    this.report.trackApi(this, 'plugin', 'registerStyleModule');
     getPluginEndpoints().registerModule(this, {
       endpoint,
       type: EndpointType.STYLE,
@@ -119,6 +124,7 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi {
+    this.report.trackApi(this, 'plugin', 'registerCustomComponent');
     return this._registerCustomComponent(endpointName, moduleName, options);
   }
 
@@ -133,6 +139,7 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi {
+    this.report.trackApi(this, 'plugin', 'registerDynamicCustomComponent');
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
     return this._registerCustomComponent(
       fullEndpointName,
@@ -169,19 +176,23 @@
    * element for the first call.
    */
   hook(endpointName: string, options?: RegisterOptions) {
+    this.report.trackApi(this, 'plugin', 'hook');
     return this.registerCustomComponent(endpointName, undefined, options);
   }
 
   getServerInfo() {
+    this.report.trackApi(this, 'plugin', 'getServerInfo');
     return appContext.restApiService.getConfig();
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   on(eventName: EventType, callback: (...args: any[]) => any) {
+    this.report.trackApi(this, 'plugin', 'on');
     this.jsApi.addEventCallback(eventName, callback);
   }
 
   url(path?: string) {
+    this.report.trackApi(this, 'plugin', 'url');
     if (!this._url) throw new Error('plugin url not set');
     const relPath = '/plugins/' + this._name + (path || '/');
     const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
@@ -200,6 +211,7 @@
   }
 
   screenUrl(screenName?: string) {
+    this.report.trackApi(this, 'plugin', 'screenUrl');
     const origin = location.origin;
     const base = getBaseUrl();
     const tokenPart = screenName ? '/' + screenName : '';
@@ -216,21 +228,25 @@
   }
 
   get(url: string, callback?: SendCallback) {
+    this.report.trackApi(this, 'plugin', 'get');
     console.warn('.get() is deprecated! Use .restApi().get()');
     return this._send(HttpMethod.GET, url, callback);
   }
 
   post(url: string, payload: RequestPayload, callback?: SendCallback) {
+    this.report.trackApi(this, 'plugin', 'post');
     console.warn('.post() is deprecated! Use .restApi().post()');
     return this._send(HttpMethod.POST, url, callback, payload);
   }
 
   put(url: string, payload: RequestPayload, callback?: SendCallback) {
+    this.report.trackApi(this, 'plugin', 'put');
     console.warn('.put() is deprecated! Use .restApi().put()');
     return this._send(HttpMethod.PUT, url, callback, payload);
   }
 
   delete(url: string, callback?: SendCallback) {
+    this.report.trackApi(this, 'plugin', 'delete');
     console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
     return this.restApi()
       .delete(this.url(url))
@@ -286,19 +302,19 @@
   }
 
   styles(): StylesPluginApi {
-    return new GrStylesApi();
+    return new GrStylesApi(this);
   }
 
   restApi(prefix?: string): RestPluginApi {
-    return new GrPluginRestApi(prefix);
+    return new GrPluginRestApi(this, prefix);
   }
 
-  attributeHelper(element: HTMLElement) {
-    return new GrAttributeHelper(element);
+  attributeHelper(element: HTMLElement): AttributeHelperPluginApi {
+    return new GrAttributeHelper(this, element);
   }
 
   eventHelper(element: HTMLElement): EventHelperPluginApi {
-    return new GrEventHelper(element);
+    return new GrEventHelper(this, element);
   }
 
   popup(): Promise<PopupPluginApi>;
@@ -314,6 +330,7 @@
   }
 
   screen(screenName: string, moduleName?: string) {
+    this.report.trackApi(this, 'plugin', 'screen');
     if (moduleName && typeof moduleName !== 'string') {
       console.error(
         '.screen(pattern, callback) deprecated, use ' +
@@ -328,6 +345,7 @@
   }
 
   _getScreenName(screenName: string) {
+    this.report.trackApi(this, 'plugin', '_getScreenName');
     return `${this.getPluginName()}-screen-${screenName}`;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index d4b51a8..595fb4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -18,6 +18,7 @@
 import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
+import {LifeCycle} from '../../../constants/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
@@ -25,9 +26,12 @@
 export class GrReportingJsApi implements ReportingPluginApi {
   private readonly reporting = appContext.reportingService;
 
-  constructor(private readonly plugin: PluginApi) {}
+  constructor(private readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
+  }
 
   reportInteraction(eventName: string, details?: EventDetails) {
+    this.reporting.trackApi(this.plugin, 'reporting', 'reportInteraction');
     this.reporting.reportInteraction(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
@@ -35,9 +39,11 @@
   }
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
-    this.reporting.reportLifeCycle(
-      `${this.plugin.getPluginName()}-${eventName}`,
-      details
-    );
+    this.reporting.trackApi(this.plugin, 'reporting', 'reportLifeCycle');
+    this.reporting.reportLifeCycle(LifeCycle.PLUGIN_LIFE_CYCLE, {
+      ...details,
+      pluginName: this.plugin.getPluginName(),
+      eventName,
+    });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
index 9e3876b..ffc2fbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -28,7 +28,6 @@
   let plugin;
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
@@ -63,11 +62,11 @@
       assert.isTrue(appContext.reportingService.reportLifeCycle.called);
       assert.equal(
           appContext.reportingService.reportLifeCycle.lastCall.args[0],
-          'testplugin-test'
+          'Plugin life cycle'
       );
       assert.deepEqual(
           appContext.reportingService.reportLifeCycle.lastCall.args[1],
-          {}
+          {pluginName: 'testplugin', eventName: 'test'}
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 60a07a4..8dc42fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -22,7 +22,6 @@
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-info_html';
@@ -61,9 +60,7 @@
 }
 
 @customElement('gr-label-info')
-export class GrLabelInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelInfo extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index c640d23..0efe73d 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -101,7 +101,10 @@
           </gr-label>
         </td>
         <td>
-          <gr-account-link account="[[mappedLabel.account]]"></gr-account-link>
+          <gr-account-link
+            account="[[mappedLabel.account]]"
+            change="[[change]]"
+          ></gr-account-link>
         </td>
         <td>
           <gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index b2365aa..e8a38ec 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -17,8 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-label-info.js';
-import {isHidden} from '../../../test/test-utils.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {isHidden, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
index 46b10cc..d3c08f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -21,7 +21,6 @@
  * used in gr-label-info.
  */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
@@ -35,9 +34,7 @@
 }
 
 @customElement('gr-label')
-export class GrLabel extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrLabel extends TooltipMixin(LegacyElementMixin(PolymerElement)) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index 4240c77..84467ee 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -16,7 +16,6 @@
  */
 import '../gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-labeled-autocomplete_html';
@@ -32,9 +31,7 @@
   };
 }
 @customElement('gr-labeled-autocomplete')
-export class GrLabeledAutocomplete extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabeledAutocomplete extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 4d65874..607f75d 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-limited-text_html';
@@ -35,7 +34,7 @@
  */
 @customElement('gr-limited-text')
 export class GrLimitedText extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index fa244f6..36a360b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -19,7 +19,6 @@
 import '../gr-icons/gr-icons';
 import '../gr-limited-text/gr-limited-text';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
@@ -33,9 +32,7 @@
 }
 
 @customElement('gr-linked-chip')
-export class GrLinkedChip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLinkedChip extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
index e2c2d7f..44e740d 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-linked-text_html';
@@ -36,9 +35,7 @@
 }
 
 @customElement('gr-linked-text')
-export class GrLinkedText extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLinkedText extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index bf532ef..71b8bc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -18,7 +18,6 @@
 import '@polymer/iron-icon/iron-icon';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-list-view_html';
@@ -38,9 +37,7 @@
 const DEBOUNCER_RELOAD = 'reload';
 
 @customElement('gr-list-view')
-class GrListView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrListView extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -67,9 +64,9 @@
   path?: string;
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.cancelDebouncer(DEBOUNCER_RELOAD);
+    super.disconnectedCallback();
   }
 
   _filterChanged(newFilter?: string, oldFilter?: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index 20e5296..a89e051 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-overlay_html';
@@ -37,7 +36,7 @@
 
 @customElement('gr-overlay')
 export class GrOverlay extends IronOverlayMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement)),
+  LegacyElementMixin(PolymerElement),
   IronOverlayBehavior as IronOverlayBehavior
 ) {
   static get template() {
@@ -136,7 +135,7 @@
   _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
     let iters = 0;
     const step = () => {
-      this.async(() => {
+      setTimeout(() => {
         if (this.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index e009499..623e6a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-page-nav_html';
@@ -34,9 +33,7 @@
 }
 
 @customElement('gr-page-nav')
-export class GrPageNav extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPageNav extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -51,14 +48,14 @@
     this.bodyScrollHandler = () => this._handleBodyScroll();
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     window.addEventListener('scroll', this.bodyScrollHandler);
   }
 
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     window.removeEventListener('scroll', this.bodyScrollHandler);
+    super.disconnectedCallback();
   }
 
   _handleBodyScroll() {
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 6db1925..9dee18f 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-branch-picker_html';
@@ -44,9 +43,7 @@
   };
 }
 @customElement('gr-repo-branch-picker')
-export class GrRepoBranchPicker extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoBranchPicker extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -75,8 +72,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     if (this.repo) {
       this.$.repoInput.setText(this.repo);
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index ec59ddc..145883b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -52,6 +52,7 @@
   Base64File,
   Base64FileContent,
   Base64ImageFile,
+  BasePatchSetNum,
   BlameInfo,
   BranchInfo,
   BranchInput,
@@ -1774,6 +1775,15 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
+    const query = [`intopic:"${topic}"`].join(' ');
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/intopic:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -2220,14 +2230,14 @@
 
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffCommentsOutput>;
 
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
@@ -2253,14 +2263,14 @@
 
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffRobotCommentsOutput>;
 
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
@@ -2289,14 +2299,14 @@
 
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffCommentsOutput>;
 
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
@@ -2356,7 +2366,7 @@
     changeNum: NumericChangeId,
     endpoint: '/comments' | '/drafts',
     params?: FetchParams,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<GetDiffCommentsOutput>;
@@ -2365,7 +2375,7 @@
     changeNum: NumericChangeId,
     endpoint: '/robotcomments',
     params?: FetchParams,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<GetDiffRobotCommentsOutput>;
@@ -2374,7 +2384,7 @@
     changeNum: NumericChangeId,
     endpoint: string,
     params?: FetchParams,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
index 4eef8a2f..3a5a587 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -16,8 +16,7 @@
  */
 
 import '../../../../test/common-test-setup-karma.js';
-import {SiteBasedCache} from './gr-rest-api-helper.js';
-import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
+import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
 import {appContext} from '../../../../services/app-context.js';
 
 suite('gr-rest-api-helper tests', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
index a2c1253..27f6cb1 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
@@ -30,9 +29,7 @@
  * GrSelect `gr-select` component.
  */
 @customElement('gr-select')
-export class GrSelect extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSelect extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return html` <slot></slot> `;
   }
@@ -57,7 +54,7 @@
       // Async needed for firefox to populate value. It was trying to do it
       // before options from a dom-repeat were rendered previously.
       // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
-      this.async(() => {
+      setTimeout(() => {
         // TODO(TS): maybe should check for undefined before assigning
         // or fallback to ''
         this.nativeSelect.value = this.bindValue!;
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 27f4069..133c6d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-shell-command_html';
@@ -29,9 +28,7 @@
 }
 
 @customElement('gr-shell-command')
-class GrShellCommand extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrShellCommand extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 885db2a..3bc4c84 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -21,7 +21,6 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-textarea_html';
@@ -82,7 +81,7 @@
  */
 @customElement('gr-textarea')
 export class GrTextarea extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -186,7 +185,7 @@
     // Put the cursor at the end always.
     textarea.selectionStart = textarea.value.length;
     textarea.selectionEnd = textarea.selectionStart;
-    this.async(() => {
+    setTimeout(() => {
       textarea.focus();
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index cfd9e81..ab3b5c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-tooltip-content_html';
@@ -33,7 +32,7 @@
  */
 @customElement('gr-tooltip-content')
 export class GrTooltipContent extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index c1a8eb2..8652df0 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-tooltip_html';
@@ -32,9 +31,7 @@
 }
 
 @customElement('gr-tooltip')
-export class GrTooltip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrTooltip extends LegacyElementMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 75ad608..e60c614 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -88,7 +88,6 @@
 
       /** @override */
       disconnectedCallback() {
-        super.disconnectedCallback();
         // NOTE: if you define your own `detached` in your component
         // then this won't take affect (as its not a class yet)
         this._handleHideTooltip();
@@ -96,6 +95,7 @@
           this.removeEventListener('mouseenter', this.mouseenterHandler);
         }
         window.removeEventListener('scroll', this.windowScrollHandler);
+        super.disconnectedCallback();
       }
 
       @observe('hasTooltip')
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index ab85b87..85931b4 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -960,10 +960,10 @@
 
       /** @override */
       disconnectedCallback() {
-        super.disconnectedCallback();
         if (shortcutManager.detachHost(this)) {
           this.removeOwnKeyBindings();
         }
+        super.disconnectedCallback();
       }
 
       keyboardShortcuts() {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index f03c7e6..36b2eb5 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -250,6 +250,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-tooltip",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/polymer",
     license: SharedLicenses.Polymer2017
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 5e15990..2f6aa05 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -24,6 +24,7 @@
     "@polymer/paper-listbox": "^3.0.1",
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
+    "@polymer/paper-tooltip": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
     "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.9.2",
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
index 85c92d3..66681ee 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
@@ -32,7 +32,6 @@
   };
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     provider = new GrEmailSuggestionsProvider(appContext.restApiService);
   });
 
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
index 3ce9d9d..1a14abf 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
@@ -33,7 +33,6 @@
   };
 
   setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({}));
     provider = new GrGroupSuggestionsProvider(appContext.restApiService);
   });
 
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index b2bdcfe..e7472de 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -46,10 +46,18 @@
 // Must only be used by the change service or whatever is in control of this
 // model.
 export function updateState(change?: ParsedChangeInfo) {
-  privateState$.next({
-    ...privateState$.getValue(),
-    change,
-  });
+  const current = privateState$.getValue();
+  // We want to make it easy for subscribers to react to change changes, so we
+  // are explicitly emitting and additional `undefined` when the change number
+  // changes. So if you are subscribed to the latestPatchsetNumber for example,
+  // then you can rely on emissions even if the old and the new change have the
+  // same latestPatchsetNumber.
+  if (change !== undefined && current.change !== undefined) {
+    if (change._number !== current.change._number) {
+      privateState$.next({...current, change: undefined});
+    }
+  }
+  privateState$.next({...current, change});
 }
 
 /**
@@ -91,9 +99,6 @@
  *
  * Note that this selector can emit a patchNum without the change being
  * available!
- *
- * TODO: It would be good to assert/enforce somehow that currentPatchNum$ cannot
- * emit 'PARENT'.
  */
 export const currentPatchNum$: Observable<
   PatchSetNum | undefined
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 4524813..c292fb5 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -16,21 +16,16 @@
  */
 import {routerChangeNum$} from '../router/router-model';
 import {updateState} from './change-model';
-import {tap} from 'rxjs/operators';
 import {ParsedChangeInfo} from '../../types/types';
 
 export class ChangeService {
-  // TODO: In the future we will want to make restApiService.getChangeDetail()
-  // calls from a switchMap() here. For now just make sure to invalidate the
-  // change when no changeNum is set.
-  private routerChangeNumEffect = routerChangeNum$.pipe(
-    tap(changeNum => {
-      if (!changeNum) updateState(undefined);
-    })
-  );
-
   constructor() {
-    this.routerChangeNumEffect.subscribe();
+    // TODO: In the future we will want to make restApiService.getChangeDetail()
+    // calls from a switchMap() here. For now just make sure to invalidate the
+    // change when no changeNum is set.
+    routerChangeNum$.subscribe(changeNum => {
+      if (!changeNum) updateState(undefined);
+    });
   }
 
   /**
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 28aca73..1c5b862 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -26,6 +26,7 @@
   RunStatus,
 } from '../../api/checks';
 import {distinctUntilChanged, map} from 'rxjs/operators';
+import {PatchSetNumber} from '../../types/common';
 
 // This is a convenience type for working with results, because when working
 // with a bunch of results you will typically also want to know about the run
@@ -41,29 +42,44 @@
 }
 
 interface ChecksState {
-  [name: string]: ChecksProviderState;
+  patchsetNumber?: PatchSetNumber;
+  providerNameToState: {
+    [name: string]: ChecksProviderState;
+  };
 }
 
-const initialState: ChecksState = {};
+const initialState: ChecksState = {
+  providerNameToState: {},
+};
 
 const privateState$ = new BehaviorSubject(initialState);
 
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
-export const aPluginHasRegistered$ = checksState$.pipe(
+export const checksPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumber),
+  distinctUntilChanged()
+);
+
+export const checksProviderState$ = checksState$.pipe(
+  map(state => state.providerNameToState),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksProviderState$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
-export const someProvidersAreLoading$ = checksState$.pipe(
+export const someProvidersAreLoading$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state).some(providerState => providerState.loading);
   }),
   distinctUntilChanged()
 );
 
-export const allActions$ = checksState$.pipe(
+export const allActions$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -75,7 +91,7 @@
   })
 );
 
-export const allRuns$ = checksState$.pipe(
+export const allRuns$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -87,7 +103,19 @@
   })
 );
 
-export const allResults$ = checksState$.pipe(
+export const checkToPluginMap$ = checksProviderState$.pipe(
+  map(state => {
+    const map = new Map<string, string>();
+    for (const [pluginName, providerState] of Object.entries(state)) {
+      for (const run of providerState.runs) {
+        map.set(run.checkName, pluginName);
+      }
+    }
+    return map;
+  })
+);
+
+export const allResults$ = checksProviderState$.pipe(
   map(state => {
     return Object.values(state)
       .reduce(
@@ -112,7 +140,8 @@
   config?: ChecksApiConfig
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState[pluginName] = {
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
     pluginName,
     loading: false,
     config,
@@ -188,8 +217,9 @@
 
 export function updateStateSetLoading(pluginName: string) {
   const nextState = {...privateState$.getValue()};
-  nextState[pluginName] = {
-    ...nextState[pluginName],
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
     loading: true,
   };
   privateState$.next(nextState);
@@ -201,11 +231,18 @@
   actions: Action[] = []
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState[pluginName] = {
-    ...nextState[pluginName],
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
     loading: false,
     runs: [...runs],
     actions: [...actions],
   };
   privateState$.next(nextState);
 }
+
+export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+  const nextState = {...privateState$.getValue()};
+  nextState.patchsetNumber = patchsetNumber;
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index b11eada..f0c566b 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -16,9 +16,9 @@
  */
 
 import {
+  filter,
   switchMap,
   takeWhile,
-  tap,
   throttleTime,
   withLatestFrom,
 } from 'rxjs/operators';
@@ -29,11 +29,14 @@
   FetchResponse,
   ResponseCode,
 } from '../../api/checks';
-import {change$, currentPatchNum$} from '../change/change-model';
+import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
 import {
   updateStateSetLoading,
+  checkToPluginMap$,
   updateStateSetProvider,
   updateStateSetResults,
+  checksPatchsetNumber$,
+  updateStateSetPatchset,
 } from './checks-model';
 import {
   BehaviorSubject,
@@ -42,14 +45,35 @@
   Observable,
   of,
   Subject,
+  timer,
 } from 'rxjs';
+import {PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
 
   private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
 
-  private changeAndPatchNum$ = change$.pipe(withLatestFrom(currentPatchNum$));
+  private checkToPluginMap = new Map<string, string>();
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  constructor() {
+    checkToPluginMap$.subscribe(map => {
+      this.checkToPluginMap = map;
+    });
+    latestPatchNum$.subscribe(num => {
+      updateStateSetPatchset(num);
+    });
+    document.addEventListener('visibilitychange', () => {
+      this.documentVisibilityChange$.next(undefined);
+    });
+  }
+
+  setPatchset(num: PatchSetNumber) {
+    updateStateSetPatchset(num);
+  }
 
   reload(pluginName: string) {
     this.reloadSubjects[pluginName].next();
@@ -59,6 +83,12 @@
     Object.keys(this.providers).forEach(key => this.reload(key));
   }
 
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
   register(
     pluginName: string,
     provider: ChecksProvider,
@@ -67,39 +97,53 @@
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
     updateStateSetProvider(pluginName, config);
-    // Both, changed numbers and and announceUpdate request should trigger.
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
     combineLatest([
-      this.changeAndPatchNum$,
+      changeNum$,
+      checksPatchsetNumber$,
       this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+      timer(0, pollIntervalMs),
+      this.documentVisibilityChange$,
     ])
       .pipe(
         takeWhile(_ => !!this.providers[pluginName]),
+        filter(_ => document.visibilityState !== 'hidden'),
+        withLatestFrom(change$),
         switchMap(
-          ([[change, patchNum], _]): Observable<FetchResponse> => {
-            if (!change || !patchNum || typeof patchNum !== 'number') {
+          ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+            if (
+              !change ||
+              !changeNum ||
+              !patchNum ||
+              typeof patchNum !== 'number'
+            ) {
               return of({
                 responseCode: ResponseCode.OK,
                 runs: [],
               });
             }
             const data: ChangeData = {
-              changeNumber: change._number,
+              changeNumber: changeNum,
               patchsetNumber: patchNum,
               repo: change.project,
+              commmitMessage: getCurrentRevision(change)?.commit?.message,
             };
             updateStateSetLoading(pluginName);
             return from(this.providers[pluginName].fetch(data));
           }
-        ),
-        tap(response => {
-          updateStateSetResults(
-            pluginName,
-            response.runs ?? [],
-            response.actions
-          );
-        })
+        )
       )
-      .subscribe(() => {});
-    this.reload(pluginName);
+      .subscribe(response => {
+        updateStateSetResults(
+          pluginName,
+          response.runs ?? [],
+          response.actions
+        );
+      });
   }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index ea532ea..9b75bd9 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -14,7 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Action, Category, CheckRun, RunStatus} from '../../api/checks';
+import {
+  Action,
+  Category,
+  CheckResult,
+  CheckRun,
+  RunStatus,
+} from '../../api/checks';
 import {assertNever} from '../../utils/common-util';
 
 export function worstCategory(run: CheckRun) {
@@ -118,6 +124,16 @@
   return hasCompleted(run) && hasResultsOf(run, category);
 }
 
+export function hasResults(run: CheckRun): boolean {
+  return (run.results ?? []).length > 0;
+}
+
+export function allResults(runs: CheckRun[]): CheckResult[] {
+  return runs.reduce((results: CheckResult[], run: CheckRun) => {
+    return [...results, ...(run.results ?? [])];
+  }, []);
+}
+
 export function hasResultsOf(run: CheckRun, category: Category) {
   return getResultsOf(run, category).length > 0;
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 878b8f7..ff950b8 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -29,7 +29,6 @@
   // with the new Checks plugin API.
   CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
   NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
-  PORTING_COMMENTS = 'UiFeature__porting_comments',
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   COMMENT_CONTEXT = 'UiFeature__comment_context',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 4196513..c11896f 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -17,6 +17,8 @@
 
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution, LifeCycle} from '../../constants/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
@@ -86,17 +88,18 @@
    * @param elapsed The time elapsed of the RPC.
    */
   reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
-  reportLifeCycle(eventName: string, details?: EventDetails): void;
+  reportLifeCycle(eventName: LifeCycle, details?: EventDetails): void;
 
   /**
    * Use this method, if you want to check/count how often a certain code path
    * is executed. For example you can use this method to prove that certain code
    * paths are dead: Add reportExecution(), check the logs a week later, then
-   * safely remove the coe.
+   * safely remove the code.
    *
    * Every execution is only reported once per session.
    */
-  reportExecution(id: string, details: EventDetails): void;
+  reportExecution(id: Execution, details?: EventDetails): void;
+  trackApi(plugin: PluginApi, object: string, method: string): void;
   reportInteraction(eventName: string, details?: EventDetails): void;
   /**
    * A draft interaction was started. Update the time-between-draft-actions
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 631a4e0..bd564c5 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -20,6 +20,8 @@
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution, LifeCycle} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -448,17 +450,23 @@
 
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
-    const eventName = `Visibility changed to ${document.visibilityState}`;
-    this.reporter(
-      LIFECYCLE.TYPE,
-      LIFECYCLE.CATEGORY.VISIBILITY,
-      eventName,
-      undefined,
-      {
-        hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
-      },
-      true
-    );
+    let eventName;
+    if (document.visibilityState === 'hidden') {
+      eventName = LifeCycle.VISIBILILITY_HIDDEN;
+    } else if (document.visibilityState === 'visible') {
+      eventName = LifeCycle.VISIBILILITY_VISIBLE;
+    }
+    if (eventName)
+      this.reporter(
+        LIFECYCLE.TYPE,
+        LIFECYCLE.CATEGORY.VISIBILITY,
+        eventName,
+        undefined,
+        {
+          hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+        },
+        false
+      );
   }
 
   /**
@@ -606,7 +614,13 @@
   }
 
   reportExtension(name: string) {
-    this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.EXTENSION_DETECTED,
+      LifeCycle.EXTENSION_DETECTED,
+      undefined,
+      {name}
+    );
   }
 
   pluginLoaded(name: string) {
@@ -620,7 +634,7 @@
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
-      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      LifeCycle.PLUGINS_INSTALLED,
       undefined,
       {pluginsList: pluginsList || []},
       true
@@ -769,7 +783,7 @@
     }
   }
 
-  reportLifeCycle(eventName: string, details: EventDetails) {
+  reportLifeCycle(eventName: LifeCycle, details: EventDetails) {
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.DEFAULT,
@@ -791,19 +805,25 @@
     );
   }
 
-  reportExecution(id: string, details: EventDetails) {
+  reportExecution(name: Execution, details?: EventDetails) {
+    const id = `${name}${JSON.stringify(details)}`;
     if (this.executionReported.has(id)) return;
     this.executionReported.add(id);
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.EXECUTION,
-      id,
+      name,
       undefined,
       details,
-      false
+      true // skip console log
     );
   }
 
+  trackApi(pluginApi: PluginApi, object: string, method: string) {
+    const plugin = pluginApi?.getPluginName() ?? 'unknown';
+    this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
+  }
+
   /**
    * A draft interaction was started. Update the time-between-draft-actions
    * timer.
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 484ce45..c4030c9 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -16,6 +16,8 @@
  */
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -64,9 +66,13 @@
   error: () => {
     log('error');
   },
-  reportExecution: (id: string, details: EventDetails) => {
+  reportExecution: (id: Execution, details?: EventDetails) => {
     log(`reportExecution '${id}': ${JSON.stringify(details)}`);
   },
+  trackApi: (pluginApi: PluginApi, object: string, method: string) => {
+    const plugin = pluginApi?.getPluginName() ?? 'unknown';
+    log(`trackApi '${plugin}', ${object}, ${method}`);
+  },
   reportExtension: () => {},
   reportInteraction: (eventName: string, details?: EventDetails) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index 6e56ab1..9b71908 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -322,7 +322,8 @@
   test('reportExtension', () => {
     service.reportExtension('foo');
     assert.isTrue(service.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'foo'
+        'lifecycle', 'Extension detected', 'Extension detected', undefined,
+        {name: 'foo'}
     ));
   });
 
@@ -340,6 +341,16 @@
     ));
   });
 
+  test('trackApi reports same event only once', () => {
+    sinon.spy(service, '_reportEvent');
+    const pluginApi = {getPluginName: () => 'test'};
+    service.trackApi(pluginApi, 'object', 'method');
+    service.trackApi(pluginApi, 'object', 'method');
+    assert.isTrue(service.reporter.calledOnce);
+    service.trackApi(pluginApi, 'object', 'method2');
+    assert.isTrue(service.reporter.calledTwice);
+  });
+
   test('report start time', () => {
     service.reporter.restore();
     sinon.stub(window.performance, 'now').returns(42);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 4742e65..30bf490 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -24,6 +24,7 @@
   AccountInfo,
   ActionNameToActionInfoMap,
   Base64FileContent,
+  BasePatchSetNum,
   BlameInfo,
   BranchInfo,
   BranchInput,
@@ -422,7 +423,7 @@
   ): Promise<GetDiffCommentsOutput>;
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -440,7 +441,7 @@
   ): Promise<GetDiffRobotCommentsOutput>;
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -458,7 +459,7 @@
   ): Promise<GetDiffCommentsOutput>;
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -659,6 +660,7 @@
     topic: string,
     changeNum: NumericChangeId
   ): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 18c12b0..c9602a7 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -45,6 +45,7 @@
     --red-200: #f6aea9;
     --red-50: #fce8e6;
     --blue-900: #174ea6;
+    --blue-800: #185abc;
     --blue-700: #1967d2;
     --blue-200: #aecbfa;
     --blue-50: #e8f0fe;
@@ -61,10 +62,13 @@
     --green-200: #a8dab5;
     --green-50: #e6f4ea;
     --gray-900: #202124;
+    --gray-800: #3c4043;
     --gray-700: #5f6368;
     --gray-300: #dadce0;
     --gray-100: #f1f3f4;
     --gray-50: #f8f9fa;
+    --purple-900: #681da8;
+    --purple-50: #f3e8fd;
 
     --chip-color: var(--gray-900);
     --error-color: var(--red-900);
@@ -74,7 +78,7 @@
     --warning-background: var(--orange-50);
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
-    --selected-foreground: var(--blue-700);
+    --selected-foreground: var(--blue-800);
     --selected-background: var(--blue-50);
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
@@ -130,7 +134,7 @@
     --disabled-button-background-color: #e8eaed;
     --primary-button-background-color: var(--blue-700);
     --selection-background-color: rgba(161, 194, 250, 0.1);
-    --tooltip-background-color: #333;
+    --tooltip-background-color: var(--gray-900);
     /* comment background colors */
     --comment-background-color: #e8eaed;
     --robot-comment-background-color: var(--blue-50);
@@ -179,6 +183,7 @@
     --font-weight-h2: 400;
     --font-weight-h3: 400;
     --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+    --code-hint-font-weight: 500;
 
     /* spacing */
     --spacing-xxs: 1px;
@@ -219,16 +224,15 @@
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --light-add-highlight-color: #d8fed8;
     --light-rebased-add-highlight-color: #eef;
-    --diff-moved-in-background: #e4f7fb;
-    --diff-moved-out-background: #f3e8fd;
-    --diff-moved-in-label-background: #007b83;
-    --diff-moved-out-label-background: #681da8;
+    --diff-moved-in-background: var(--cyan-50);
+    --diff-moved-out-background: var(--purple-50);
+    --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-out-label-color: var(--purple-900);
     --light-remove-add-highlight-color: #fff8dc;
     --light-remove-highlight-color: #ffebee;
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
-    --ranged-comment-chip-background: #b06000;
-    --ranged-comment-chip-text-color: #feefe3;
+    --ranged-comment-hint-text-color: var(--orange-900);
 
     /* syntax colors */
     --syntax-attr-color: #219;
@@ -275,6 +279,14 @@
     --iron-overlay-backdrop: {
       transition: none;
     };
+    --paper-tooltip-duration-in: 0;
+    --paper-tooltip-duration-out: 0;
+    --paper-tooltip-background: var(--tooltip-background-color);
+    --paper-tooltip-opacity: 1.0;
+    --paper-tooltip-text-color: var(--tooltip-text-color);
+    --paper-tooltip: {
+      font-size: var(--font-size-small);
+    }
   }
   @media screen and (max-width: 50em) {
     html {
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 5455a24..5c97597 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -66,7 +66,7 @@
       --reviewed-text-color: #dadce0;
       --vote-text-color: black;
       --status-text-color: black;
-      --tooltip-text-color: white;
+      --tooltip-text-color: var(--gray-200);
       --negative-red-text-color: #f28b82;
       --positive-green-text-color: #81c995;
 
@@ -87,7 +87,7 @@
       --disabled-button-background-color: #484a4d;
       --primary-button-background-color: var(--link-color);
       --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: #111;
+      --tooltip-background-color: var(--gray-800);
       /* comment background colors */
       --comment-background-color: #3c3f43;
       --robot-comment-background-color: #1e3a5f;
@@ -142,16 +142,15 @@
       --diff-trailing-whitespace-indicator: #ff9ad2;
       --light-add-highlight-color: #0f401f;
       --light-rebased-add-highlight-color: #487165;
-      --diff-moved-in-background: #006066;
-      --diff-moved-out-background: #681da8;
-      --diff-moved-in-label-background: #cbf0f8;
-      --diff-moved-out-label-background: #e9d2fd;
+      --diff-moved-in-background: #1d4042;
+      --diff-moved-out-background: #230e34;
+      --diff-moved-in-label-color: var(--cyan-50);
+      --diff-moved-out-label-color: var(--purple-50);
       --light-remove-add-highlight-color: #2f3f2f;
       --light-remove-highlight-color: #320404;
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
-      --ranged-comment-chip-background: #e8f0fe;
-      --ranged-comment-chip-text-color: #174ea6;
+      --ranged-comment-hint-text-color: var(--blue-50);
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index f2ca48c..526aabe 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 
@@ -23,9 +22,7 @@
  * This is an "abstract" class for tests. The descendant must define a template
  * for this element and a tagName - see createCommentApiMockWithTemplateElement below
  */
-class CommentApiMock extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
+class CommentApiMock extends LegacyElementMixin(PolymerElement) {
   static get properties() {
     return {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 5efc562..395c9f67 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -78,6 +78,7 @@
   createConfig,
   createPreferences,
   createServerInfo,
+  createSubmittedTogetherInfo,
 } from '../test-data-generators';
 import {
   createDefaultDiffPrefs,
@@ -253,11 +254,14 @@
     return Promise.resolve([]);
   },
   getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
-    throw new Error('getChangesSubmittedTogether() not implemented by mock.');
+    return Promise.resolve(createSubmittedTogetherInfo());
   },
   getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index d2c1cd2..2ac8ffe 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -61,6 +61,9 @@
   Requirement,
   RequirementType,
   UrlEncodedCommentId,
+  BasePatchSetNum,
+  RelatedChangeAndCommitInfo,
+  SubmittedTogetherInfo,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -89,6 +92,7 @@
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -200,10 +204,12 @@
   };
 }
 
-export function createCommitInfoWithRequiredCommit(): CommitInfoWithRequiredCommit {
+export function createCommitInfoWithRequiredCommit(
+  commit = 'commit'
+): CommitInfoWithRequiredCommit {
   return {
     ...createCommit(),
-    commit: 'commit' as CommitId,
+    commit: commit as CommitId,
   };
 }
 
@@ -221,12 +227,12 @@
 export function createEditRevision(): EditRevisionInfo {
   return {
     _number: EditPatchSetNum,
-    basePatchNum: 1 as PatchSetNum,
+    basePatchNum: 1 as BasePatchSetNum,
     commit: createCommit(),
   };
 }
 
-export function createChangeMessage(id = 'cm_id_1'): ChangeMessageInfo {
+export function createChangeMessageInfo(id = 'cm_id_1'): ChangeMessageInfo {
   return {
     id: id as ChangeMessageId,
     date: dateToTimestamp(TEST_CHANGE_CREATED),
@@ -234,6 +240,15 @@
   };
 }
 
+export function createChangeMessage(id = 'cm_id_1'): ChangeMessage {
+  return {
+    ...createChangeMessageInfo(id),
+    type: '',
+    expanded: false,
+    commentThreads: [],
+  };
+}
+
 export function createRevisions(
   count: number
 ): {[revisionId: string]: RevisionInfo} {
@@ -265,7 +280,7 @@
   const messageDate = TEST_CHANGE_CREATED;
   for (let i = 0; i < count; i++) {
     messages.push({
-      ...createChangeMessage((i + messageIdStart).toString(16)),
+      ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
     });
     messageDate.setDate(messageDate.getDate() + 1);
@@ -453,6 +468,7 @@
     message: 'hello world',
     updated: '2018-02-13 22:48:48.018000000' as Timestamp,
     unresolved: false,
+    path: 'abc.txt',
   };
 }
 
@@ -569,9 +585,26 @@
 }
 
 export function createCommentThread(comments: UIComment[]) {
+  if (!comments.length) {
+    throw new Error('comment is required to create a thread');
+  }
   comments = comments.map(comment => {
     return {...createComment(), ...comment};
   });
   const threads = createCommentThreads(comments);
-  return threads.length > 0 ? threads[0] : {};
+  return threads[0];
+}
+
+export function createRelatedChangeAndCommitInfo(): RelatedChangeAndCommitInfo {
+  return {
+    project: TEST_PROJECT_NAME,
+    commit: createCommitInfoWithRequiredCommit(),
+  };
+}
+
+export function createSubmittedTogetherInfo(): SubmittedTogetherInfo {
+  return {
+    changes: [],
+    non_visible_changes: 0,
+  };
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 25366fe..50f465f 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,6 +23,7 @@
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {SinonSpy} from 'sinon/pkg/sinon-esm';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -42,10 +43,31 @@
   return getComputedStyle(el).display === 'none';
 }
 
-export function query(el: Element | undefined, selectors: string) {
-  if (!el) return null;
-  const root = el.shadowRoot || el;
-  return root.querySelector(selectors);
+export function queryAll<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): NodeListOf<E> {
+  if (!el) assert.fail('element not defined');
+  const root = el.shadowRoot ?? el;
+  return root.querySelectorAll<E>(selector);
+}
+
+export function query<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  const root = el.shadowRoot ?? el;
+  return root.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) assert.fail(`selector '${selector}' did not match anything'`);
+  return found;
 }
 
 // Some tests/elements can define its own binding. We want to restore bindings
@@ -143,6 +165,11 @@
   return sinon.spy(appContext.restApiService, method);
 }
 
+export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
+  Parameters<F>,
+  ReturnType<F>
+>;
+
 /**
  * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
  * otherwise the backdrop stays around in the DOM for too long waiting for
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 711b11b..0649002 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -76,11 +76,13 @@
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
 export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
 
 export const EditPatchSetNum = 'edit' as PatchSetNum;
 // TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
 // without 'parent'.
-export const ParentPatchSetNum = 'PARENT' as PatchSetNum;
+export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
 
 export type ChangeId = BrandType<string, '_changeId'>;
 export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
@@ -550,7 +552,7 @@
   commit_with_footers?: boolean;
   push_certificate?: PushCertificateInfo;
   description?: string;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 /**
@@ -746,7 +748,7 @@
 export interface AuthInfo {
   auth_type: AuthType; // docs incorrectly names it 'type'
   use_contributor_agreements?: boolean;
-  contributor_agreements?: ContributorAgreementInfo;
+  contributor_agreements?: ContributorAgreementInfo[];
   editable_account_fields: EditableAccountField[];
   login_url?: string;
   login_text?: string;
@@ -1174,6 +1176,7 @@
   change_message_id?: string;
   commit_id?: string;
   context_lines?: ContextLine[];
+  source_content_type?: string;
 }
 
 export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
@@ -1619,7 +1622,7 @@
  */
 export interface PatchRange {
   patchNum: PatchSetNum;
-  basePatchNum: PatchSetNum;
+  basePatchNum: BasePatchSetNum;
 }
 
 /**
@@ -1936,7 +1939,7 @@
  */
 export interface EditInfo {
   commit: CommitInfo;
-  base_patch_set_number: PatchSetNum;
+  base_patch_set_number: BasePatchSetNum;
   base_revision: string;
   ref: GitRef;
   fetch?: ProtocolToFetchInfoMap;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 5965453..d389533 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -20,6 +20,7 @@
 import {FetchRequest} from './types';
 import {MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
+import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export interface TitleChangeEventDetail {
   title: string;
@@ -33,6 +34,32 @@
   }
 }
 
+export interface ChangeMessageDeletedEventDetail {
+  message: ChangeMessage;
+}
+
+export type ChangeMessageDeletedEvent = CustomEvent<
+  ChangeMessageDeletedEventDetail
+>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'change-message-deleted': ChangeMessageDeletedEvent;
+  }
+}
+
+export interface ReplyEventDetail {
+  message: ChangeMessage;
+}
+
+export type ReplyEvent = CustomEvent<ReplyEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    reply: ReplyEvent;
+  }
+}
+
 export interface PageErrorEventDetail {
   response: Response;
 }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index be80411..e3e1ada 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -21,6 +21,7 @@
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {
   AccountInfo,
+  BasePatchSetNum,
   ChangeId,
   ChangeViewChangeInfo,
   CommitId,
@@ -237,7 +238,7 @@
 export interface EditRevisionInfo extends Partial<RevisionInfo> {
   // EditRevisionInfo has less required properties then RevisionInfo
   _number: PatchSetNum;
-  basePatchNum: PatchSetNum;
+  basePatchNum: BasePatchSetNum;
   commit: CommitInfo;
 }
 
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index a7f8b49..018d70d 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -213,7 +213,7 @@
   return owner || uploader || reviewer || cc;
 }
 
-export function getCurrentRevision(change?: ChangeInfo) {
+export function getCurrentRevision(change?: ChangeInfo | ParsedChangeInfo) {
   if (!change?.revisions || !change?.current_revision) return undefined;
   return change.revisions[change.current_revision];
 }
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index de12a2a..110832d 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -25,6 +25,7 @@
   PatchRange,
   ParentPatchSetNum,
   ContextLine,
+  BasePatchSetNum,
 } from '../types/common';
 import {CommentSide, Side} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -283,12 +284,16 @@
   } else {
     return {
       patchNum: latestPatchNum,
-      basePatchNum: comment.patch_set,
+      basePatchNum: comment.patch_set as BasePatchSetNum,
     };
   }
 }
 
-export function computeDiffFromContext(context: ContextLine[], path: string) {
+export function computeDiffFromContext(
+  context: ContextLine[],
+  path: string,
+  content_type?: string
+) {
   // do not render more than 20 lines of context
   context = context.slice(0, 20);
   const diff: DiffInfo = {
@@ -300,7 +305,7 @@
     },
     meta_b: {
       name: path,
-      content_type: '',
+      content_type: content_type || '',
       lines: context.length + context?.[0].line_number,
       web_links: [],
     },
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
deleted file mode 100644
index 4ce95ef..0000000
--- a/polygerrit-ui/app/utils/comment-util_test.js
+++ /dev/null
@@ -1,206 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {
-  isUnresolved, getPatchRangeForCommentUrl, createCommentThreads, sortComments,
-} from './comment-util.js';
-import {createComment} from '../test/test-data-generators.js';
-import {CommentSide, Side} from '../constants/constants.js';
-import {ParentPatchSetNum} from '../types/common.js';
-
-suite('comment-util', () => {
-  test('isUnresolved', () => {
-    assert.isFalse(isUnresolved(undefined));
-    assert.isFalse(isUnresolved({comments: []}));
-    assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
-    assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
-    assert.isTrue(isUnresolved(
-        {comments: [{unresolved: false}, {unresolved: true}]}));
-    assert.isFalse(isUnresolved(
-        {comments: [{unresolved: true}, {unresolved: false}]}));
-  });
-
-  test('getPatchRangeForCommentUrl', () => {
-    test('comment created with side=PARENT does not navigate to latest ps',
-        () => {
-          const comment = {
-            ...createComment(),
-            id: 'c4',
-            line: 10,
-            patch_set: 4,
-            side: CommentSide.PARENT,
-            path: '/COMMIT_MSG',
-          };
-          assert.deepEqual(getPatchRangeForCommentUrl(comment, 11), {
-            basePatchNum: ParentPatchSetNum,
-            patchNum: 4,
-          });
-        });
-  });
-
-  test('comments sorting', () => {
-    const comments = [
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        diffSide: Side.LEFT,
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        diffSide: Side.LEFT,
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        diffSide: Side.LEFT,
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-    ];
-    const sortedComments = sortComments(comments);
-    assert.equal(sortedComments[0], comments[1]);
-    assert.equal(sortedComments[1], comments[2]);
-    assert.equal(sortedComments[2], comments[0]);
-  });
-
-  suite('createCommentThreads', () => {
-    test('creates threads from individual comments', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          line: 1,
-          patch_set: 1,
-          path: 'some/path',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          line: 1,
-          in_reply_to: 'sallys_confession',
-          patch_set: 1,
-          path: 'some/path',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-          patch_set: 1,
-          path: 'some/path',
-        },
-      ];
-
-      const actualThreads = createCommentThreads(comments,
-          {basePatchNum: 1, patchNum: 4});
-
-      assert.equal(actualThreads.length, 2);
-
-      assert.equal(actualThreads[0].diffSide, Side.LEFT);
-      assert.equal(actualThreads[0].comments.length, 2);
-      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
-      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-      assert.equal(actualThreads[0].patchNum, 1);
-      assert.equal(actualThreads[0].line, 1);
-
-      assert.equal(actualThreads[1].diffSide, Side.LEFT);
-      assert.equal(actualThreads[1].comments.length, 1);
-      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-      assert.equal(actualThreads[1].patchNum, 1);
-      assert.equal(actualThreads[1].line, 'FILE');
-    });
-
-    test('derives patchNum and range', () => {
-      const comments = [{
-        id: 'betsys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        patch_set: 5,
-        path: '/p',
-        line: 1,
-      }];
-
-      const expectedThreads = [
-        {
-          diffSide: Side.LEFT,
-          commentSide: CommentSide.REVISION,
-          path: '/p',
-          rootId: 'betsys_confession',
-          mergeParentNum: undefined,
-          comments: [{
-            id: 'betsys_confession',
-            path: '/p',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            patch_set: 5,
-            line: 1,
-          }],
-          patchNum: 5,
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          line: 1,
-        },
-      ];
-
-      assert.deepEqual(
-          createCommentThreads(comments, {basePatchNum: 5, patchNum: 10}),
-          expectedThreads);
-    });
-
-    test('does not thread unrelated comments at same location', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          diffSide: Side.LEFT,
-          path: '/p',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          diffSide: Side.LEFT,
-          path: '/p',
-        },
-      ];
-      assert.equal(createCommentThreads(comments).length, 2);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
new file mode 100644
index 0000000..29eb5b7
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -0,0 +1,257 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  isUnresolved,
+  getPatchRangeForCommentUrl,
+  createCommentThreads,
+  sortComments,
+} from './comment-util.js';
+import {
+  createComment,
+  createCommentThread,
+} from '../test/test-data-generators.js';
+import {CommentSide, Side} from '../constants/constants.js';
+import {
+  BasePatchSetNum,
+  ParentPatchSetNum,
+  PatchSetNum,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../types/common.js';
+
+suite('comment-util', () => {
+  test('isUnresolved', () => {
+    const thread = createCommentThread([createComment()]);
+
+    assert.isFalse(isUnresolved(undefined));
+    assert.isFalse(isUnresolved(thread));
+
+    assert.isTrue(
+      isUnresolved({
+        ...thread,
+        comments: [{...createComment(), unresolved: true}],
+      })
+    );
+    assert.isFalse(
+      isUnresolved({
+        ...thread,
+        comments: [{...createComment(), unresolved: false}],
+      })
+    );
+    assert.isTrue(
+      isUnresolved({
+        ...thread,
+        comments: [
+          {...createComment(), unresolved: false},
+          {...createComment(), unresolved: true},
+        ],
+      })
+    );
+    assert.isFalse(
+      isUnresolved({
+        ...thread,
+        comments: [
+          {...createComment(), unresolved: true},
+          {...createComment(), unresolved: false},
+        ],
+      })
+    );
+  });
+
+  test('getPatchRangeForCommentUrl', () => {
+    test('comment created with side=PARENT does not navigate to latest ps', () => {
+      const comment = {
+        ...createComment(),
+        id: 'c4' as UrlEncodedCommentId,
+        line: 10,
+        patch_set: 4 as PatchSetNum,
+        side: CommentSide.PARENT,
+        path: '/COMMIT_MSG',
+      };
+      assert.deepEqual(getPatchRangeForCommentUrl(comment, 11 as PatchSetNum), {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 4 as PatchSetNum,
+      });
+    });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft' as UrlEncodedCommentId,
+        message: 'i do not like either of you',
+        diffSide: Side.LEFT,
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+        line: 1,
+        diffSide: Side.LEFT,
+      },
+      {
+        id: 'jacks_reply' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+        diffSide: Side.LEFT,
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
+
+  suite('createCommentThreads', () => {
+    test('creates threads from individual comments', () => {
+      const comments = [
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+          line: 1,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+          line: 1,
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+        {
+          id: 'new_draft' as UrlEncodedCommentId,
+          message: 'i do not like either of you' as UrlEncodedCommentId,
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000' as Timestamp,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+      ];
+
+      const actualThreads = createCommentThreads(comments, {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 4 as PatchSetNum,
+      });
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(actualThreads[0].diffSide, Side.LEFT);
+      assert.equal(actualThreads[0].comments.length, 2);
+      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+      assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
+      assert.equal(actualThreads[0].line, 1);
+
+      assert.equal(actualThreads[1].diffSide, Side.LEFT);
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
+      assert.equal(actualThreads[1].line, 'FILE');
+    });
+
+    test('derives patchNum and range', () => {
+      const comments = [
+        {
+          id: 'betsys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000' as Timestamp,
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          patch_set: 5 as PatchSetNum,
+          path: '/p',
+          line: 1,
+        },
+      ];
+
+      const expectedThreads = [
+        {
+          diffSide: Side.LEFT,
+          commentSide: CommentSide.REVISION,
+          path: '/p',
+          rootId: 'betsys_confession' as UrlEncodedCommentId,
+          mergeParentNum: undefined,
+          comments: [
+            {
+              id: 'betsys_confession' as UrlEncodedCommentId,
+              path: '/p',
+              message: 'i like you, jack',
+              updated: '2015-12-24 15:00:10.396000000' as Timestamp,
+              range: {
+                start_line: 1,
+                start_character: 1,
+                end_line: 1,
+                end_character: 2,
+              },
+              patch_set: 5 as PatchSetNum,
+              line: 1,
+            },
+          ],
+          patchNum: 5 as PatchSetNum,
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          line: 1,
+        },
+      ];
+
+      assert.deepEqual(
+        createCommentThreads(comments, {
+          basePatchNum: 5 as BasePatchSetNum,
+          patchNum: 10 as PatchSetNum,
+        }),
+        expectedThreads
+      );
+    });
+
+    test('does not thread unrelated comments at same location', () => {
+      const comments = [
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+          diffSide: Side.LEFT,
+          path: '/p',
+        },
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+          diffSide: Side.LEFT,
+          path: '/p',
+        },
+      ];
+      assert.equal(createCommentThreads(comments).length, 2);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index f4d6d51..08b5e49 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -122,3 +122,7 @@
     set.add(value);
   }
 }
+
+export function unique<T>(item: T, index: number, array: T[]) {
+  return array.indexOf(item) === index;
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index cf590e0..46b1b49 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -132,3 +132,15 @@
     })
   );
 }
+
+export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
+  el: EventTarget,
+  eventName: K
+): Promise<HTMLElementEventMap[K]> {
+  return new Promise<HTMLElementEventMap[K]>(resolve => {
+    const callback = (event: HTMLElementEventMap[K]) => {
+      resolve(event);
+    };
+    el.addEventListener(eventName, callback as EventListener, {once: true});
+  });
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 40e3eef..a0946a3 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -3,11 +3,13 @@
   ChangeInfo,
   PatchSetNum,
   EditPatchSetNum,
-  BrandType,
   ParentPatchSetNum,
+  PatchSetNumber,
+  BasePatchSetNum,
 } from '../types/common';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {check} from './common-util';
 
 /**
  * @license
@@ -46,7 +48,7 @@
 
 interface PatchRange {
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 /**
@@ -82,9 +84,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(
-  psn: PatchSetNum
-): psn is BrandType<number, '_patchSet'> {
+export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
 
@@ -250,19 +250,21 @@
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
-): PatchSetNum | undefined {
+): PatchSetNumber | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
-  if (allPatchSets[0].num === EditPatchSetNum) {
-    return allPatchSets[1].num;
+  let latest = allPatchSets[0].num;
+  if (latest === EditPatchSetNum) {
+    latest = allPatchSets[1].num;
   }
-  return allPatchSets[0].num;
+  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  return latest;
 }
 
 export function computePredecessor(
   patchset?: PatchSetNum
-): PatchSetNum | undefined {
+): BasePatchSetNum | undefined {
   if (
     !patchset ||
     patchset === ParentPatchSetNum ||
@@ -271,7 +273,7 @@
     return undefined;
   }
   if (patchset === 1) return ParentPatchSetNum;
-  return (Number(patchset) - 1) as PatchSetNum;
+  return (Number(patchset) - 1) as BasePatchSetNum;
 }
 
 export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 1b400cf..6aae67f 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -26,3 +26,7 @@
 export function addQuotesWhen(string: string, cond: boolean): string {
   return cond ? `"${string}"` : string;
 }
+
+export function charsOnly(s: string): string {
+  return s.replace(/[^a-zA-Z]+/g, '');
+}
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index f977ab6..4115062 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -78,3 +78,33 @@
   const withoutPlus = url.replace(/\+/g, '%20');
   return decodeURIComponent(withoutPlus);
 }
+
+/**
+ * @param path URL path including search params, but without host
+ */
+export function toPathname(path: string) {
+  const i = path.indexOf('?');
+  const hasQuery = i > -1;
+  const pathname = hasQuery ? path.slice(0, i) : path;
+  return pathname;
+}
+
+/**
+ * @param path URL path including search params, but without host
+ */
+export function toSearchParams(path: string) {
+  const i = path.indexOf('?');
+  const hasQuery = i > -1;
+  const querystring = hasQuery ? path.slice(i + 1) : '';
+  return new URLSearchParams(querystring);
+}
+
+/**
+ * @param pathname URL path without search params
+ * @param params
+ */
+export function toPath(pathname: string, searchParams: URLSearchParams) {
+  const paramString = searchParams.toString();
+  const middle = paramString ? '?' : '';
+  return pathname + middle + paramString;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
index b1b17f4..5cd4bb4 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -20,7 +20,11 @@
   getBaseUrl,
   getDocsBaseUrl,
   _testOnly_clearDocsBaseUrlCache,
-  encodeURL, singleDecodeURL,
+  encodeURL,
+  singleDecodeURL,
+  toPath,
+  toPathname,
+  toSearchParams,
 } from './url-util.js';
 
 suite('url-util tests', () => {
@@ -124,4 +128,23 @@
       });
     });
   });
+
+  test('toPathname', () => {
+    assert.equal(toPathname('asdf'), 'asdf');
+    assert.equal(toPathname('asdf?qwer=zxcv'), 'asdf');
+  });
+
+  test('toSearchParams', () => {
+    assert.equal(toSearchParams('asdf').toString(), '');
+    assert.equal(toSearchParams('asdf?qwer=zxcv').get('qwer'), 'zxcv');
+  });
+
+  test('toPathname', () => {
+    const params = new URLSearchParams();
+    assert.equal(toPath('asdf', params), 'asdf');
+    params.set('qwer', 'zxcv');
+    assert.equal(toPath('asdf', params), 'asdf?qwer=zxcv');
+    assert.equal(toPath(toPathname('asdf?qwer=zxcv'),
+        toSearchParams('asdf?qwer=zxcv')), 'asdf?qwer=zxcv');
+  });
 });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index ec3b7a0..f94f5ad 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -310,6 +310,14 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/paper-tooltip@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-tooltip/-/paper-tooltip-3.0.1.tgz#cdbb06442737513f081437c6302842170ce714dc"
+  integrity sha512-yiUk09opTEnE1lK+tb501ENb+yQBi4p++Ep0eGJAHesVYKVMPNgPphVKkIizkDaU+n0SE+zXfTsRbYyOMDYXSg==
+  dependencies:
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
diff --git a/proto/cache.proto b/proto/cache.proto
index 4fd037d..874e60a 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -524,13 +524,14 @@
 }
 
 // Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
-// Next ID: 2
+// Next ID: 3
 message AllCommentContextProto {
   message CommentContextProto {
     int32 line_number = 1;
     string context_line = 2;
   }
   repeated CommentContextProto context = 1;
+  string content_type = 2;
 }
 
 // Serialized key for
@@ -617,7 +618,7 @@
 
 // Serialized form of
 // com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 9
+// Next ID: 12
 message FileDiffOutputProto {
   // Next ID: 5
   message Edit {
@@ -632,6 +633,11 @@
     Edit edit = 1;
     bool due_to_rebase = 2;
   }
+  // Next ID: 3
+  message ComparisonType {
+    int32 parent_num = 1;
+    bool auto_merge = 2;
+  }
   string old_path = 1;
   string new_path = 2;
   string change_type = 3;
@@ -640,4 +646,7 @@
   int64 size = 6;
   int64 size_delta = 7;
   repeated TaggedEdit edits = 8;
+  bytes old_commit = 9;
+  bytes new_commit = 10;
+  ComparisonType comparison_type = 11;
 }
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 15b1797..d96ffc2 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -60,7 +60,7 @@
     if len(parts) == 3:
         group_id, artifact_id, version = parts
     elif len(parts) == 4:
-        group_id, artifact_id, version, packaging = parts
+        group_id, artifact_id, version, classifier = parts
     elif len(parts) == 5:
         group_id, artifact_id, version, packaging, classifier = parts
     else:
@@ -158,7 +158,10 @@
     srcjar = None
     if ctx.attr.src_sha1 or ctx.attr.attach_source:
         srcjar = jar + "-src.jar"
-        srcurl = url + "-sources.jar"
+        srcurl = url
+        if coordinates.classifier != None:
+            srcurl = url.replace("-" + coordinates.classifier, "")
+        srcurl += "-sources.jar"
         srcjar_path = ctx.path("jar/" + srcjar)
         args = [python, script, "-o", srcjar_path, "-u", srcurl]
         if ctx.attr.src_sha1:
diff --git a/tools/node_tools/node_modules_licenses/utils.ts b/tools/node_tools/node_modules_licenses/utils.ts
index 5f8e7b3..e73bf96 100644
--- a/tools/node_tools/node_modules_licenses/utils.ts
+++ b/tools/node_tools/node_modules_licenses/utils.ts
@@ -22,6 +22,15 @@
   process.exit(1);
 }
 
+// Bazel params may be surrounded with quotes
+function removeSurrondedQuotes(str: string): string {
+  return str.startsWith("'") && str.endsWith("'") ?
+      str.slice(1, -1) : str;
+}
+
 export function readMultilineParamFile(path: string): string[] {
-  return fs.readFileSync(path, {encoding: 'utf-8'}).split(/\r?\n/).filter(f => f.length > 0);
+  return fs.readFileSync(path, {encoding: 'utf-8'})
+      .split(/\r?\n/)
+      .filter(f => f.length > 0)
+      .map(removeSurrondedQuotes);
 }
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 22ee330..bbfe3a8 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -25,7 +25,7 @@
 # this higher can make builds faster by allowing more jobs to run in parallel.
 # Setting it too high can result in jobs that timeout, however, while waiting
 # for a remote machine to execute them.
-build:remote --jobs=100
+build:remote --jobs=200
 build:remote --disk_cache=
 
 # Set several flags related to specifying the platform, toolchain and java