Merge "Partially revert "A11y - make visible that tab title is focused""
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index e34071f..c45de05 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -104,7 +104,7 @@
 Now that you have a simple version of Gerrit running, use the installation to
 explore the user interface and learn about Gerrit. For more detailed
 installation instructions, see
-link:[Standalone Daemon Installation Guide](install.html).
+link:install.html[Standalone Daemon Installation Guide].
 
 GERRIT
 ------
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 1672436..4bf84b5 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -28,7 +28,7 @@
 
 Unlike a typical server database, access to Git repositories is not
 marshalled through a single process or a set of inter communicating
-processes. Unfortuntatlely the design of the on-disk layout of a Git
+processes. Unfortunately the design of the on-disk layout of a Git
 repository does not allow for 100% race free operations when accessed by
 multiple actors concurrently. These design shortcomings are more likely
 to impact the operations of busy repositories since racy conditions are
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 410bf42..0caebfc 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1342,6 +1342,7 @@
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
+    "disable_keyboard_shortcuts": true,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -1392,6 +1393,7 @@
     "size_bar_in_change_table": true,
     "diff_view": "SIDE_BY_SIDE",
     "publish_comments_on_push": true,
+    "disable_keyboard_shortcuts": true,
     "work_in_progress_by_default": true,
     "mute_common_path_prefixes": true,
     "my": [
@@ -2883,6 +2885,8 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`disable_keyboard_shortcuts`     |not set if `false`|
+Whether to disable all keyboard shortcuts.
 |============================================
 
 [[query-limit-info]]
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index de9a43d..e4b0eea 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -54,6 +56,7 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 1c38c59..55a9976 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -20,6 +20,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class LabelTypes {
   protected List<LabelType> labelTypes;
@@ -36,12 +37,12 @@
     return labelTypes;
   }
 
-  public LabelType byLabel(LabelId labelId) {
-    return byLabel().get(labelId.get().toLowerCase());
+  public Optional<LabelType> byLabel(LabelId labelId) {
+    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase()));
   }
 
-  public LabelType byLabel(String labelName) {
-    return byLabel().get(labelName.toLowerCase());
+  public Optional<LabelType> byLabel(String labelName) {
+    return Optional.ofNullable(byLabel().get(labelName.toLowerCase()));
   }
 
   private Map<String, LabelType> byLabel() {
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index ef3cbeb..617b827 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -150,6 +150,7 @@
     return builder;
   }
 
+  @Nullable
   public String getName() {
     return getNameKey() != null ? getNameKey().get() : null;
   }
@@ -183,7 +184,7 @@
 
   @Override
   public final String toString() {
-    return Optional.of(getName()).orElse("<null>");
+    return Optional.ofNullable(getName()).orElse("<null>");
   }
 
   public abstract Builder toBuilder();
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index cd3ebb9..ea7c609 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 39db61d..375aa54 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -46,6 +46,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -112,6 +113,8 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -225,6 +228,7 @@
   public static final String XD_METHOD = "$m";
   public static final int SC_UNPROCESSABLE_ENTITY = 422;
   public static final int SC_TOO_MANY_REQUESTS = 429;
+  public static final int SC_CLIENT_CLOSED_REQUEST = 499;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -711,29 +715,43 @@
                 e);
       } catch (Exception e) {
         cause = Optional.of(e);
-        statusCode = SC_INTERNAL_SERVER_ERROR;
 
-        Optional<ExceptionHook.Status> status = getStatus(e);
-        statusCode = status.map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
-
-        if (res.isCommitted()) {
-          responseBytes = 0;
-          if (statusCode == SC_INTERNAL_SERVER_ERROR) {
-            logger.atSevere().withCause(e).log(
-                "Error in %s %s, response already committed", req.getMethod(), uriForLogging(req));
-          } else {
-            logger.atWarning().log(
-                "Response for %s %s already committed, wanted to set status %d",
-                req.getMethod(), uriForLogging(req), statusCode);
-          }
+        Optional<RequestCancelledException> requestCancelledException =
+            RequestCancelledException.getFromCausalChain(e);
+        if (requestCancelledException.isPresent()) {
+          statusCode =
+              getCancellationStatusCode(requestCancelledException.get().getCancellationReason());
+          responseBytes =
+              replyError(
+                  req, res, statusCode, getCancellationMessage(requestCancelledException.get()), e);
         } else {
-          res.reset();
-          traceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+          statusCode = SC_INTERNAL_SERVER_ERROR;
 
-          if (status.isPresent()) {
-            responseBytes = reply(req, res, e, status.get(), getUserMessages(traceContext, e));
+          Optional<ExceptionHook.Status> status = getStatus(e);
+          statusCode =
+              status.map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
+
+          if (res.isCommitted()) {
+            responseBytes = 0;
+            if (statusCode == SC_INTERNAL_SERVER_ERROR) {
+              logger.atSevere().withCause(e).log(
+                  "Error in %s %s, response already committed",
+                  req.getMethod(), uriForLogging(req));
+            } else {
+              logger.atWarning().log(
+                  "Response for %s %s already committed, wanted to set status %d",
+                  req.getMethod(), uriForLogging(req), statusCode);
+            }
           } else {
-            responseBytes = replyInternalServerError(req, res, e, getUserMessages(traceContext, e));
+            res.reset();
+            traceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+
+            if (status.isPresent()) {
+              responseBytes = reply(req, res, e, status.get(), getUserMessages(traceContext, e));
+            } else {
+              responseBytes =
+                  replyInternalServerError(req, res, e, getUserMessages(traceContext, e));
+            }
           }
         }
       } finally {
@@ -1939,6 +1957,28 @@
     return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
+  private static int getCancellationStatusCode(RequestStateProvider.Reason cancellationReason) {
+    switch (cancellationReason) {
+      case CLIENT_CLOSED_REQUEST:
+        return SC_CLIENT_CLOSED_REQUEST;
+      case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+      case SERVER_DEADLINE_EXCEEDED:
+        return SC_REQUEST_TIMEOUT;
+    }
+    logger.atSevere().log("Unexpected cancellation reason: %s", cancellationReason);
+    return SC_INTERNAL_SERVER_ERROR;
+  }
+
+  private static String getCancellationMessage(
+      RequestCancelledException requestCancelledException) {
+    StringBuilder msg = new StringBuilder(requestCancelledException.formatCancellationReason());
+    if (requestCancelledException.getCancellationMessage().isPresent()) {
+      msg.append("\n\n");
+      msg.append(requestCancelledException.getCancellationMessage().get());
+    }
+    return msg.toString();
+  }
+
   private static boolean acceptsGzip(HttpServletRequest req) {
     if (req != null) {
       String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING);
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 344549e..b18f499 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -438,7 +438,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ null);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index d60bc8f..326ddf4 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -151,8 +151,10 @@
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap : approvalsUtil.byPatchSet(notes, change.currentPatchSetId())) {
-      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
-      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+      Optional<LabelType> type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type.isPresent()
+          && ap.value() == 1
+          && type.get().getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 7bd2c53..93738b0 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -512,7 +512,10 @@
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
-    String externalIdUpdateMessage = "Batch update for " + updatedAccounts.size() + " accounts";
+    String externalIdUpdateMessage =
+        updatedAccounts.size() == 1
+            ? Iterables.getOnlyElement(updatedAccounts).message
+            : "Batch update for " + updatedAccounts.size() + " accounts";
     for (UpdatedAccount updatedAccount : updatedAccounts) {
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
index 1efbd37..8d409e5 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -52,6 +52,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -397,14 +398,14 @@
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      LabelType type = labelTypes.byLabel(psa.labelId());
+      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
       // Only compute modified files if there is a relevant label, since this is expensive.
       if (modifiedFiles == null
-          && type != null
-          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+          && type.isPresent()
+          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
         modifiedFiles = listModifiedFiles(project, ps, priorPatchSet);
       }
-      if (type == null) {
+      if (!type.isPresent()) {
         logger.atFine().log(
             "approval %d on label %s of patch set %d of change %d cannot be copied"
                 + " to patch set %d because the label no longer exists on project %s",
@@ -416,8 +417,8 @@
             project.getName());
         continue;
       }
-      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type, modifiedFiles)
-          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type, kind)) {
+      if (!canCopyBasedOnBooleanLabelConfigs(project, psa, ps.id(), kind, type.get(), modifiedFiles)
+          && !canCopyBasedOnCopyCondition(notes, psa, ps.id(), type.get(), kind)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index b1e85e9..a1cdd99 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -61,6 +61,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -299,8 +300,12 @@
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
-      LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
+      Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
+      if (!lt.isPresent()) {
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", vote.getKey()));
+      }
+      cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.label(), psa.value());
@@ -310,11 +315,11 @@
 
   public static void checkLabel(LabelTypes labelTypes, String name, Short value)
       throws BadRequestException {
-    LabelType label = labelTypes.byLabel(name);
-    if (label == null) {
+    Optional<LabelType> label = labelTypes.byLabel(name);
+    if (!label.isPresent()) {
       throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
     }
-    if (label.getValue(value) == null) {
+    if (label.get().getValue(value) == null) {
       throw new BadRequestException(
           String.format("label \"%s\": %d is not a valid value", name, value));
     }
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
new file mode 100644
index 0000000..05530a5
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "cancellation",
+    srcs = glob(
+        ["*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib/commons:lang",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
new file mode 100644
index 0000000..d89701f
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -0,0 +1,80 @@
+// 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.cancellation;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import org.apache.commons.lang.WordUtils;
+
+/** Exception to signal that the current request is cancelled and should be aborted. */
+public class RequestCancelledException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * Checks whether the given exception was caused by {@link RequestCancelledException}. If yes, the
+   * {@link RequestCancelledException} is returned. If not, {@link Optional#empty()} is returned.
+   */
+  public static Optional<RequestCancelledException> getFromCausalChain(Throwable e) {
+    return Throwables.getCausalChain(e).stream()
+        .filter(RequestCancelledException.class::isInstance)
+        .map(RequestCancelledException.class::cast)
+        .findFirst();
+  }
+
+  private final RequestStateProvider.Reason cancellationReason;
+  private final Optional<String> cancellationMessage;
+
+  /**
+   * Create a {@code RequestCancelledException}.
+   *
+   * @param cancellationReason the reason why the request is cancelled
+   * @param cancellationMessage an optional message providing details about the cancellation
+   */
+  public RequestCancelledException(
+      RequestStateProvider.Reason cancellationReason, @Nullable String cancellationMessage) {
+    super(createMessage(cancellationReason, cancellationMessage));
+    this.cancellationReason = cancellationReason;
+    this.cancellationMessage = Optional.ofNullable(cancellationMessage);
+  }
+
+  private static String createMessage(
+      RequestStateProvider.Reason cancellationReason, @Nullable String message) {
+    StringBuilder messageBuilder = new StringBuilder();
+    messageBuilder.append(String.format("Request cancelled: %s", cancellationReason.name()));
+    if (message != null) {
+      messageBuilder.append(String.format(" (%s)", message));
+    }
+    return messageBuilder.toString();
+  }
+
+  /** Returns the reason why the request is cancelled. */
+  public RequestStateProvider.Reason getCancellationReason() {
+    return cancellationReason;
+  }
+
+  /** Returns the cancellation reason as a user-readable string. */
+  public String formatCancellationReason() {
+    return WordUtils.capitalizeFully(cancellationReason.name().replaceAll("_", " "));
+  }
+
+  /**
+   * Returns a message providing details about the cancellation, or {@link Optional#empty()} if none
+   * is available.
+   */
+  public Optional<String> getCancellationMessage() {
+    return cancellationMessage;
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateContext.java b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
new file mode 100644
index 0000000..183d779
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
@@ -0,0 +1,136 @@
+// 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.cancellation;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Context that allows to register {@link RequestStateProvider}s.
+ *
+ * <p>The registered {@link RequestStateProvider}s are stored in {@link ThreadLocal} so that they
+ * can be accessed during the request execution (via {@link #getRequestStateProviders()}.
+ *
+ * <p>On {@link #close()} the {@link RequestStateProvider}s that have been registered by this {@code
+ * RequestStateContext} instance are removed from {@link ThreadLocal}.
+ *
+ * <p>Nesting {@code RequestStateContext}s is possible.
+ *
+ * <p>Currently there is no logic to automatically copy the {@link RequestStateContext} to
+ * background threads, but implementing this may be considered in the future. This means that by
+ * default we only support cancellation of the main thread, but not of background threads. That's
+ * fine as all significant work is being done in the main thread.
+ *
+ * <p>{@link com.google.gerrit.server.util.RequestContext} is also a context that is available for
+ * the time of the request, but it is not suitable to manage registrations of {@link
+ * RequestStateProvider}s. Hence {@link RequestStateProvider} registrations are managed by a
+ * separate context, which is this class, {@link RequestStateContext}:
+ *
+ * <ul>
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is an interface that has many
+ *       implementations and hence cannot manage a {@link ThreadLocal} state.
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is not an {@link AutoCloseable} and
+ *       hence cannot cleanup any {@link ThreadLocal} state on close (turning it into an {@link
+ *       AutoCloseable} would require a large refactoring).
+ *   <li>Despite the name {@link com.google.gerrit.server.util.RequestContext} is not only used for
+ *       requests scopes but also for other scopes that are not a request (e.g. plugin invocations,
+ *       email sending, manual scopes).
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is not copied to background and should
+ *       not be, but for {@link RequestStateContext} we may consider doing this in the future.
+ * </ul>
+ */
+public class RequestStateContext implements AutoCloseable {
+  /** The {@link RequestStateProvider}s that have been registered for the thread. */
+  private static final ThreadLocal<Set<RequestStateProvider>> threadLocalRequestStateProviders =
+      new ThreadLocal<>();
+
+  /**
+   * Aborts the current request by throwing a {@link RequestCancelledException} if any of the
+   * registered {@link RequestStateProvider}s reports the request as cancelled.
+   *
+   * @throws RequestCancelledException thrown if the current request is cancelled and should be
+   *     aborted
+   */
+  public static void abortIfCancelled() throws RequestCancelledException {
+    getRequestStateProviders()
+        .forEach(
+            requestStateProvider ->
+                requestStateProvider.checkIfCancelled(
+                    (reason, message) -> {
+                      throw new RequestCancelledException(reason, message);
+                    }));
+  }
+
+  /** Returns the {@link RequestStateProvider}s that have been registered for the thread. */
+  @VisibleForTesting
+  static ImmutableSet<RequestStateProvider> getRequestStateProviders() {
+    if (threadLocalRequestStateProviders.get() == null) {
+      return ImmutableSet.of();
+    }
+    return ImmutableSet.copyOf(threadLocalRequestStateProviders.get());
+  }
+
+  /** Opens a {@code RequestStateContext}. */
+  public static RequestStateContext open() {
+    return new RequestStateContext();
+  }
+
+  /**
+   * The {@link RequestStateProvider}s that have been registered by this {@code
+   * RequestStateContext}.
+   */
+  private Set<RequestStateProvider> requestStateProviders = new HashSet<>();
+
+  private RequestStateContext() {}
+
+  /**
+   * Registers a {@link RequestStateProvider}.
+   *
+   * @param requestStateProvider the {@link RequestStateProvider} that should be registered
+   * @return the {@code RequestStateContext} instance for chaining calls
+   */
+  public RequestStateContext addRequestStateProvider(RequestStateProvider requestStateProvider) {
+    if (threadLocalRequestStateProviders.get() == null) {
+      threadLocalRequestStateProviders.set(new HashSet<>());
+    }
+    if (threadLocalRequestStateProviders.get().add(requestStateProvider)) {
+      requestStateProviders.add(requestStateProvider);
+    }
+    return this;
+  }
+
+  /**
+   * Closes this {@code RequestStateContext}.
+   *
+   * <p>Ensures that all {@link RequestStateProvider}s that have been registered by this {@code
+   * RequestStateContext} instance are removed from {@link #threadLocalRequestStateProviders}.
+   *
+   * <p>If no {@link RequestStateProvider}s remain in {@link #threadLocalRequestStateProviders},
+   * {@link #threadLocalRequestStateProviders} is unset.
+   */
+  @Override
+  public void close() {
+    if (threadLocalRequestStateProviders.get() != null) {
+      requestStateProviders.forEach(
+          requestStateProvider ->
+              threadLocalRequestStateProviders.get().remove(requestStateProvider));
+      if (threadLocalRequestStateProviders.get().isEmpty()) {
+        threadLocalRequestStateProviders.remove();
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
new file mode 100644
index 0000000..683ca1d
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -0,0 +1,60 @@
+// 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.cancellation;
+
+import com.google.gerrit.common.Nullable;
+
+/** Interface that provides information about the state of the current request. */
+public interface RequestStateProvider {
+  /**
+   * Checks whether the current request is cancelled.
+   *
+   * <p>Invoked by Gerrit to check whether the current request is cancelled and should be aborted.
+   *
+   * <p>If the current request is cancelled {@link OnCancelled#onCancel(Reason, String)} is invoked
+   * on the provided callback.
+   *
+   * @param onCancelled callback that should be invoked if the request is cancelled
+   */
+  void checkIfCancelled(OnCancelled onCancelled);
+
+  /** Callback interface to be invoked if a request is cancelled. */
+  @FunctionalInterface
+  interface OnCancelled {
+    /**
+     * Callback that is invoked if the request is cancelled.
+     *
+     * @param reason the reason for the cancellation of the request
+     * @param message an optional message providing details about the cancellation
+     */
+    void onCancel(Reason reason, @Nullable String message);
+  }
+
+  /** Reason why a request is cancelled. */
+  enum Reason {
+    /** The client got disconnected or has cancelled the request. */
+    CLIENT_CLOSED_REQUEST,
+
+    /** The deadline that the client provided for the request exceeded. */
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+
+    /**
+     * A server-side deadline for the request exceeded.
+     *
+     * <p>Server-side deadlines are usually configurable, but may also be hard-coded.
+     */
+    SERVER_DEADLINE_EXCEEDED;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ad6f9c7..ab557dc 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -46,7 +46,8 @@
    *
    * @param change a Gerrit change.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
@@ -74,7 +75,8 @@
    *
    * @param project a project identifying a repository.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
index 3f7ce68..81f014d 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonExperimentImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -33,7 +34,9 @@
  * FileInfoJsonNewImpl}.
  */
 public class FileInfoJsonExperimentImpl implements FileInfoJson {
-  private final String NEW_DIFF_CACHE_FEATURE = "GerritBackendRequestFeature__use_new_diff_cache";
+  @VisibleForTesting
+  public static final String NEW_DIFF_CACHE_FEATURE =
+      "GerritBackendRequestFeature__use_new_diff_cache";
 
   private final FileInfoJsonOldImpl oldImpl;
   private final FileInfoJsonNewImpl newImpl;
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 1ca2c93..7277404 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -47,8 +47,11 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       if (base == null) {
+        // Setting parentNum=0 requests the default parent, which is the only parent for
+        // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
-            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(
+                change.getProject(), objectId, /* parentNum= */ 0));
       }
       return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
@@ -63,7 +66,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
index 55d162a..0570296 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -60,10 +60,10 @@
       Project.NameKey project, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
     PatchListKey key =
-        parentNum == -1
+        parentNum == 0
             ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
             : PatchListKey.againstParentNum(
-                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+                parentNum, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     return toFileInfoMap(project, key);
   }
 
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 30343d4..b5527d7 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -33,6 +33,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -101,12 +102,12 @@
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.labelId());
-      if (label == null) {
+      Optional<LabelType> label = labelTypes.byLabel(psa.labelId());
+      if (!label.isPresent()) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = applyTypeFloor(label, psa);
+      PatchSetApproval copy = applyTypeFloor(label.get(), psa);
       if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index acff03c..5ce121b 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -57,6 +57,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 
@@ -103,9 +104,9 @@
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels != null) {
         for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.isAllowPostSubmit())) {
-            toCheck.put(type.getName(), type);
+          Optional<LabelType> type = labelTypes.byLabel(r.label);
+          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
+            toCheck.put(type.get().getName(), type.get());
           }
         }
       }
@@ -120,18 +121,18 @@
         continue;
       }
       for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.isAllowPostSubmit())) {
+        Optional<LabelType> type = labelTypes.byLabel(r.label);
+        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
           continue;
         }
 
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+        for (LabelValue v : type.get().getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
           if (isMerged) {
             if (labels == null) {
               labels = currentLabels(filterApprovalsBy, cd);
             }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
             ok &= v.getValue() >= prev;
           }
           if (ok) {
@@ -176,21 +177,21 @@
       setAllApprovals(accountLoader, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(e.getKey());
+      if (!type.isPresent()) {
         continue;
       }
       if (standard) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
+          if (type.get().matches(psa)) {
             short val = psa.value();
             Account.Id accountId = psa.accountId();
-            setLabelScores(accountLoader, type, e.getValue(), val, accountId);
+            setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId);
           }
         }
       }
       if (detailed) {
-        setLabelValues(type, e.getValue());
+        setLabelValues(type.get(), e.getValue());
       }
     }
     return labels;
@@ -261,9 +262,9 @@
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
       allUsers.add(a.accountId());
-      LabelType type = labelTypes.byLabel(a.labelId());
-      if (type != null) {
-        labelNames.add(type.getName());
+      Optional<LabelType> type = labelTypes.byLabel(a.labelId());
+      if (type.isPresent()) {
+        labelNames.add(type.get().getName());
         // Not worth the effort to distinguish between votable/non-votable for 0
         // values on closed changes, since they can't vote anyway.
         current.put(a.accountId(), a);
@@ -292,8 +293,8 @@
 
     if (detailed) {
       labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
@@ -308,16 +309,16 @@
         }
       }
       for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.labelId());
-        if (type == null) {
+        Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
+        if (!type.isPresent()) {
           continue;
         }
 
         short val = psa.value();
-        ApprovalInfo info = byLabel.get(type.getName());
+        ApprovalInfo info = byLabel.get(type.get().getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
           info.date = psa.granted();
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
@@ -328,7 +329,7 @@
           continue;
         }
 
-        setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId);
+        setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId);
       }
     }
     return labels;
@@ -428,24 +429,24 @@
       PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
+        Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
+        if (!lt.isPresent()) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
         Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
+        PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
           if (value == 0) {
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+            value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -456,7 +457,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index d5b74a8..6189708 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -38,6 +38,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import java.util.TreeMap;
 
 @Singleton
@@ -107,10 +108,8 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.labelId());
-      if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.value()));
-      }
+      Optional<LabelType> at = labelTypes.byLabel(ca.labelId());
+      at.ifPresent(lt -> out.approvals.put(lt.getName(), formatValue(ca.value())));
     }
 
     // Add dummy approvals for all permitted labels for the user even if they
@@ -125,13 +124,13 @@
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
-          LabelType type = labelTypes.byLabel(name);
-          if (out.approvals.containsKey(name) || type == null) {
+          Optional<LabelType> type = labelTypes.byLabel(name);
+          if (out.approvals.containsKey(name) || !type.isPresent()) {
             continue;
           }
 
           try {
-            perm.check(new LabelPermission(type));
+            perm.check(new LabelPermission(type.get()));
             out.approvals.put(name, formatValue((short) 0));
           } catch (AuthException e) {
             // Do nothing.
diff --git a/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
new file mode 100644
index 0000000..098d2c2
--- /dev/null
+++ b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
@@ -0,0 +1,57 @@
+// 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.config;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class FileBasedGlobalPluginConfigProvider implements GlobalPluginConfigProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final SitePaths site;
+
+  @Inject
+  FileBasedGlobalPluginConfigProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public Config get(String pluginName) {
+    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
+    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
+      return cfg;
+    }
+
+    try {
+      cfg.load();
+    } catch (ConfigInvalidException e) {
+      // This is an error in user input, don't spam logs with a stack trace.
+      logger.atWarning().log(
+          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
+    }
+    return cfg;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 076ba46..2d49884 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -166,6 +166,7 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
@@ -466,6 +467,7 @@
     factory(MergedByPushOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
+    factory(StoreSubmitRequirementsOp.Factory.class);
 
     bind(AccountManager.class);
     bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 27d1d58..da85834 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -66,6 +66,7 @@
     bind(Config.class)
         .annotatedWith(GerritServerConfig.class)
         .toProvider(GerritServerConfigProvider.class);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(GerritIsReplica.class)
diff --git a/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
new file mode 100644
index 0000000..847708a
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
@@ -0,0 +1,21 @@
+// 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.config;
+
+import org.eclipse.jgit.lib.Config;
+
+public interface GlobalPluginConfigProvider {
+  Config get(String pluginName);
+}
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index c49e928..bd4b661 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -28,22 +27,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
 @Singleton
 public class PluginConfigFactory implements ReloadPluginListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final String EXTENSION = ".config";
 
-  private final SitePaths site;
+  private final GlobalPluginConfigProvider globalPluginConfigProvider;
   private final Provider<Config> cfgProvider;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
@@ -54,12 +46,12 @@
 
   @Inject
   PluginConfigFactory(
-      SitePaths site,
       @GerritServerConfig Provider<Config> cfgProvider,
+      GlobalPluginConfigProvider globalPluginConfigProvider,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       SecureStore secureStore) {
-    this.site = site;
+    this.globalPluginConfigProvider = globalPluginConfigProvider;
     this.cfgProvider = cfgProvider;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
@@ -211,25 +203,9 @@
       return pluginConfigs.get(pluginName);
     }
 
-    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
-    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    Config cfg = globalPluginConfigProvider.get(pluginName);
     GlobalPluginConfig pluginConfig = new GlobalPluginConfig(pluginName, cfg, secureStore);
     pluginConfigs.put(pluginName, pluginConfig);
-    if (!cfg.getFile().exists()) {
-      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
-      return pluginConfig;
-    }
-
-    try {
-      cfg.load();
-    } catch (ConfigInvalidException e) {
-      // This is an error in user input, don't spam logs with a stack trace.
-      logger.atWarning().log(
-          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
-    }
-
     return pluginConfig;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 3a4dcff..1bb694a 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -71,6 +71,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -399,7 +400,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -456,7 +457,7 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
@@ -535,10 +536,8 @@
     a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.labelId());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
+    lt.ifPresent(l -> a.description = l.getName());
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 439f53e..edd1928 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -202,10 +201,7 @@
         a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
       }
     }
-    LabelType lt = labelTypes.byLabel(approval.getKey());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    labelTypes.byLabel(approval.getKey()).ifPresent(lt -> a.description = lt.getName());
     if (approval.getValue() != null) {
       a.value = Short.toString(approval.getValue());
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 1da14f8..a5ea24d 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -600,11 +600,11 @@
       } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
-        if (lt == null) {
+        final Optional<LabelType> lt = project.getLabelTypes().byLabel(a.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
-        tag = lt.getName();
+        tag = lt.get().getName();
       }
 
       if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 2d854a5..1122551 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -131,7 +131,7 @@
   private int spinnerIndex;
   private char spinnerState = NO_SPINNER;
   private boolean done;
-  private boolean write = true;
+  private boolean clientDisconnected;
 
   private final long maxIntervalNanos;
 
@@ -343,14 +343,14 @@
   }
 
   private void send(StringBuilder s) {
-    if (write) {
+    if (!clientDisconnected) {
       try {
         out.write(Constants.encode(s.toString()));
         out.flush();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log(
             "Sending progress to client failed. Stop sending updates for task %s", taskName);
-        write = false;
+        clientDisconnected = true;
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index b59d431..5c1cf52 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -18,6 +18,7 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/util/time",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 454df66..29c2698 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -46,6 +46,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
@@ -114,6 +115,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -640,8 +642,25 @@
       Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
       commands =
           commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-      processCommandsUnsafe(commands, progress);
-      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+
+      try {
+        processCommandsUnsafe(commands, progress);
+        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+      } catch (RuntimeException e) {
+        Optional<RequestCancelledException> requestCancelledException =
+            RequestCancelledException.getFromCausalChain(e);
+        if (!requestCancelledException.isPresent()) {
+          Throwables.throwIfUnchecked(e);
+        }
+        StringBuilder msg =
+            new StringBuilder(requestCancelledException.get().formatCancellationReason());
+        if (requestCancelledException.get().getCancellationMessage().isPresent()) {
+          msg.append(
+              String.format(
+                  " (%s)", requestCancelledException.get().getCancellationMessage().get()));
+        }
+        rejectRemaining(commands, msg.toString());
+      }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
       // Currently, the test framework relies on this ordering to understand if pushes completed
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index f00b48eb..b2a31b9 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -465,10 +465,10 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        }
+        projectState
+            .getLabelTypes()
+            .byLabel(a.labelId())
+            .ifPresent(l -> current.put(l.getName(), a));
       }
     }
     return current;
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 810cd4d..5caceef 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -70,6 +70,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.SubmitRequirementProtoConverter;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -605,7 +606,7 @@
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
         allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
-        LabelType labelType = cd.getLabelTypes().byLabel(a.labelId());
+        Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
         allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
         if (owners && cd.change().getOwner().equals(a.accountId())) {
           allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
@@ -622,13 +623,15 @@
   }
 
   private static List<String> getMaxMinAnyLabels(
-      String label, short labelVal, LabelType labelType, @Nullable Account.Id accountId) {
+      String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
     List<String> labels = new ArrayList<>();
-    if (labelVal == labelType.getMaxPositive()) {
-      labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
-    }
-    if (labelVal == labelType.getMaxNegative()) {
-      labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+    if (labelType.isPresent()) {
+      if (labelVal == labelType.get().getMaxPositive()) {
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+      }
+      if (labelVal == labelType.get().getMaxNegative()) {
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+      }
     }
     labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
     return labels;
@@ -1076,6 +1079,27 @@
     return result;
   }
 
+  /** Serialized submit requirements, used for pre-populating results. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
+      storedOnly("full_submit_requirements")
+          .buildRepeatable(
+              cd ->
+                  toProtos(
+                      SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
+              (cd, field) -> parseSubmitRequirements(field, cd));
+
+  private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+    out.setSubmitRequirements(
+        StreamSupport.stream(values.spliterator(), false)
+            .map(
+                f ->
+                    SubmitRequirementProtoConverter.INSTANCE.fromProto(
+                        Protos.parseUnchecked(
+                            SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+            .collect(
+                ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
+  }
+
   /**
    * All values of all refs that were used in the course of indexing this document.
    *
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 879da4f..bee0c35 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -148,10 +148,14 @@
    * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
    * required.
    */
-  static final Schema<ChangeData> V63 = schema(V62, false);
+  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
 
   /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  static final Schema<ChangeData> V64 = schema(V63, false);
+  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
+
+  /** Added new field for submit requirements. */
+  static final Schema<ChangeData> V65 =
+      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
 
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index c60af0d..ee0168c 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/util/time",
         "//lib:gson",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 2fc19b5..681dfbc 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
@@ -208,6 +209,7 @@
     }
 
     private TraceTimer(Runnable startLogFn, Consumer<Long> doneLogFn) {
+      RequestStateContext.abortIfCancelled();
       startLogFn.run();
       this.doneLogFn = doneLogFn;
       this.stopwatch = Stopwatch.createStarted();
@@ -217,6 +219,7 @@
     public void close() {
       stopwatch.stop();
       doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+      RequestStateContext.abortIfCancelled();
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 6af2345..56528df 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -79,14 +79,14 @@
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
-        LabelType lt = labelTypes.byLabel(ca.labelId());
-        if (lt == null) {
+        Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
         if (ca.value() > 0) {
-          pos.put(ca.accountId(), lt.getName(), ca);
+          pos.put(ca.accountId(), lt.get().getName(), ca);
         } else if (ca.value() < 0) {
-          neg.put(ca.accountId(), lt.getName(), ca);
+          neg.put(ca.accountId(), lt.get().getName(), ca);
         }
       }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index e7da025..4d6b9cf 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -61,14 +61,10 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
-import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
-import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
-import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -478,11 +474,6 @@
     private static final Converter<String, ReviewerStateInternal> REVIEWER_STATE_CONVERTER =
         Enums.stringConverter(ReviewerStateInternal.class);
 
-    private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
-        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
-    private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
-        SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
-
     @Override
     public byte[] serialize(ChangeNotesState object) {
       checkArgument(object.metaId() != null, "meta ID is required in: %s", object);
@@ -539,7 +530,10 @@
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
       object
           .submitRequirementsResult()
-          .forEach(sr -> b.addSubmitRequirementResult(toSubmitRequirementResultProto(sr)));
+          .forEach(
+              sr ->
+                  b.addSubmitRequirementResult(
+                      SubmitRequirementProtoConverter.INSTANCE.toProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
         b.setMergedOnMillis(object.mergedOn().getTime());
@@ -634,53 +628,6 @@
       return builder.build();
     }
 
-    private static SubmitRequirementResultProto toSubmitRequirementResultProto(
-        SubmitRequirementResult r) {
-      SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
-      builder
-          .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
-          .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
-      if (r.applicabilityExpressionResult().isPresent()) {
-        builder.setApplicabilityExpressionResult(
-            SubmitRequirementExpressionResultSerializer.serialize(
-                r.applicabilityExpressionResult().get()));
-      }
-      builder.setSubmittabilityExpressionResult(
-          SubmitRequirementExpressionResultSerializer.serialize(
-              r.submittabilityExpressionResult()));
-      if (r.overrideExpressionResult().isPresent()) {
-        builder.setOverrideExpressionResult(
-            SubmitRequirementExpressionResultSerializer.serialize(
-                r.overrideExpressionResult().get()));
-      }
-      return builder.build();
-    }
-
-    private static SubmitRequirementResult toSubmitRequirementResult(
-        SubmitRequirementResultProto proto) {
-      SubmitRequirementResult.Builder builder =
-          SubmitRequirementResult.builder()
-              .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
-              .submitRequirement(
-                  SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
-      if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
-        builder.applicabilityExpressionResult(
-            Optional.of(
-                SubmitRequirementExpressionResultSerializer.deserialize(
-                    proto.getApplicabilityExpressionResult())));
-      }
-      builder.submittabilityExpressionResult(
-          SubmitRequirementExpressionResultSerializer.deserialize(
-              proto.getSubmittabilityExpressionResult()));
-      if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
-        builder.overrideExpressionResult(
-            Optional.of(
-                SubmitRequirementExpressionResultSerializer.deserialize(
-                    proto.getOverrideExpressionResult())));
-      }
-      return builder.build();
-    }
-
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -728,7 +675,7 @@
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
               .submitRequirementsResult(
                   proto.getSubmitRequirementResultList().stream()
-                      .map(sr -> toSubmitRequirementResult(sr))
+                      .map(sr -> SubmitRequirementProtoConverter.INSTANCE.fromProto(sr))
                       .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
               .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index 47948d7..4d99f93 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -15,16 +15,26 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
 
 /** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
 public class StoreSubmitRequirementsOp implements BatchUpdateOp {
   private final ChangeData.Factory changeDataFactory;
+  private final SubmitRequirementsEvaluator evaluator;
 
-  public StoreSubmitRequirementsOp(ChangeData.Factory changeDataFactory) {
+  public interface Factory {
+    StoreSubmitRequirementsOp create();
+  }
+
+  @Inject
+  public StoreSubmitRequirementsOp(
+      ChangeData.Factory changeDataFactory, SubmitRequirementsEvaluator evaluator) {
     this.changeDataFactory = changeDataFactory;
+    this.evaluator = evaluator;
   }
 
   @Override
@@ -32,7 +42,7 @@
     Change change = ctx.getChange();
     ChangeData changeData = changeDataFactory.create(change);
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    update.putSubmitRequirementResults(changeData.submitRequirements().values());
+    update.putSubmitRequirementResults(evaluator.getResults(changeData).values());
     return !changeData.submitRequirements().isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
new file mode 100644
index 0000000..416b850
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -0,0 +1,88 @@
+// 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.notedb;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Parser;
+import java.util.Optional;
+
+@Immutable
+public enum SubmitRequirementProtoConverter
+    implements ProtoConverter<SubmitRequirementResultProto, SubmitRequirementResult> {
+  INSTANCE;
+
+  private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+
+  @Override
+  public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
+    SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
+    builder
+        .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
+        .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+    if (r.applicabilityExpressionResult().isPresent()) {
+      builder.setApplicabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.applicabilityExpressionResult().get()));
+    }
+    builder.setSubmittabilityExpressionResult(
+        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.overrideExpressionResult().isPresent()) {
+      builder.setOverrideExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.overrideExpressionResult().get()));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public SubmitRequirementResult fromProto(SubmitRequirementResultProto proto) {
+    SubmitRequirementResult.Builder builder =
+        SubmitRequirementResult.builder()
+            .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
+            .submitRequirement(
+                SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+    if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
+      builder.applicabilityExpressionResult(
+          Optional.of(
+              SubmitRequirementExpressionResultSerializer.deserialize(
+                  proto.getApplicabilityExpressionResult())));
+    }
+    builder.submittabilityExpressionResult(
+        SubmitRequirementExpressionResultSerializer.deserialize(
+            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
+      builder.overrideExpressionResult(
+          Optional.of(
+              SubmitRequirementExpressionResultSerializer.deserialize(
+                  proto.getOverrideExpressionResult())));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public Parser<SubmitRequirementResultProto> getParser() {
+    return SubmitRequirementResultProto.parser();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 7213581..d2da736 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -46,8 +46,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return map of file paths to the file diffs. The map key is the new file path for all {@link
    *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
    *     the old file path. The map entries are not sorted by key.
@@ -56,8 +56,7 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
-      throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -85,8 +84,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @param fileName the file name for which the diff should be evaluated.
    * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
@@ -96,7 +95,7 @@
   FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parentNum,
+      int parentNum,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index f500796..3423b32 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -91,8 +91,7 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
-      throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
       return getModifiedFiles(diffParams);
@@ -120,7 +119,7 @@
   public FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parent,
+      int parent,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
@@ -376,7 +375,7 @@
       Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
-    if (parent != null) {
+    if (parent > 0) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 0c648b5..fbb6559 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -172,7 +172,7 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
-    this.parentNum = -1;
+    this.parentNum = 0;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
@@ -223,7 +223,7 @@
     this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+    checkArgument(parentNum > 0, "parentNum must be > 0");
   }
 
   @Override
@@ -326,11 +326,7 @@
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
-                notes.getProjectName(),
-                bId,
-                parentNum == -1 ? null : parentNum + 1,
-                fileName,
-                diffPrefs.ignoreWhitespace)
+                notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
     return newBuilder().toPatchScriptNew(git, fileDiffOutput);
@@ -395,7 +391,7 @@
     if (psa == null) {
       return Optional.empty();
     }
-    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkState(parentNum == 0, "expected no parentNum when psa is present");
     checkArgument(psa.get() != 0, "edit not supported for left side");
     return Optional.of(getCommitId(psa));
   }
@@ -409,10 +405,10 @@
   }
 
   private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
-    if (parentNum < 0) {
+    if (parentNum == 0) {
       return PatchListKey.againstCommit(aId, bId, whitespace);
     }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
+    return PatchListKey.againstParentNum(parentNum, bId, whitespace);
   }
 
   private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index e33b261..62cfa47 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
@@ -43,6 +44,7 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.Config;
@@ -242,10 +244,9 @@
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
-      if (!projectState
-          .getLabelTypes(notes)
-          .byLabel(patchSetApproval.labelId())
-          .isMaxPositive(patchSetApproval)) {
+      Optional<LabelType> lt =
+          projectState.getLabelTypes(notes).byLabel(patchSetApproval.labelId());
+      if (!lt.isPresent() || !lt.get().isMaxPositive(patchSetApproval)) {
         continue;
       }
       if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index b779bf7..e4fd728 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -18,11 +18,14 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -37,6 +40,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -82,7 +86,7 @@
             .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
-            .version(1)
+            .version(2)
             .loader(ModifiedFilesLoader.class);
       }
     };
@@ -139,7 +143,7 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
       if (key.aCommit().equals(ObjectId.zeroId())) {
         return ImmutableList.copyOf(modifiedFiles);
       }
@@ -202,5 +206,61 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
+
+    /**
+     * Return the {@code modifiedFiles} input list while merging {@link ChangeType#ADDED} and {@link
+     * ChangeType#DELETED} entries for the same file into a single {@link ChangeType#REWRITE} entry.
+     *
+     * <p>Background: In some cases, JGit returns two diff entries (ADDED + DELETED) for the same
+     * file path. This happens e.g. when a file's mode is changed between patchsets, for example
+     * converting a symlink file to a regular file. We identify this case and return a single
+     * modified file with changeType = {@link ChangeType#REWRITE}.
+     */
+    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
+      List<ModifiedFile> result = new ArrayList<>();
+
+      // Handle ADDED and DELETED entries separately.
+      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+      modifiedFiles.stream()
+          .filter(ModifiedFilesLoader::isAddedOrDeleted)
+          .forEach(
+              f -> {
+                if (f.oldPath().isPresent()) {
+                  byPath.get(f.oldPath().get()).add(f);
+                }
+                if (f.newPath().isPresent()) {
+                  byPath.get(f.newPath().get()).add(f);
+                }
+              });
+      for (String path : byPath.keySet()) {
+        List<ModifiedFile> entries = byPath.get(path);
+        if (entries.size() == 1) {
+          result.add(entries.get(0));
+        } else if (entries.size() == 2) {
+          result.add(getAddedEntry(entries).toBuilder().changeType(ChangeType.REWRITE).build());
+        } else {
+          // JGit error. Not expected to happen.
+          logger.atWarning().log(
+              "Found %d ADDED and DELETED entries for the same file path: %s."
+                  + " Adding the first entry only to the result.",
+              entries.size(), entries);
+          result.add(entries.get(0));
+        }
+      }
+
+      // Add the remaining non ADDED/DELETED entries to the result
+      modifiedFiles.stream().filter(f -> !isAddedOrDeleted(f)).forEach(result::add);
+      return result;
+    }
+
+    private static boolean isAddedOrDeleted(ModifiedFile f) {
+      return f.changeType() == ChangeType.ADDED || f.changeType() == ChangeType.DELETED;
+    }
+
+    private static ModifiedFile getAddedEntry(List<ModifiedFile> modifiedFiles) {
+      return modifiedFiles.get(0).changeType() == ChangeType.ADDED
+          ? modifiedFiles.get(0)
+          : modifiedFiles.get(1);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index 9512094..f4e7ca3 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -51,6 +51,8 @@
     return new AutoValue_ModifiedFile.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   /** Computes this object's weight, which is its size in bytes. */
   public int weight() {
     int weight = 1; // the changeType field
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 2f2d29b..a502a46 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -187,6 +187,10 @@
     return result;
   }
 
+  public String getDefaultPath() {
+    return oldPath().isPresent() ? oldPath().get() : newPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_GitFileDiff.Builder();
   }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 2ce6925..77b8938 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -24,7 +24,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
@@ -41,6 +45,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -105,6 +110,9 @@
 
   private final LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache;
 
+  private static final ImmutableSet<Patch.ChangeType> ADDED_AND_DELETED =
+      ImmutableSet.of(Patch.ChangeType.ADDED, Patch.ChangeType.DELETED);
+
   @Inject
   public GitFileDiffCacheImpl(
       @Named(GIT_DIFF) LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache) {
@@ -163,7 +171,7 @@
     }
 
     @Override
-    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException, DiffNotAvailableException {
       try (TraceTimer timer =
           TraceContext.newTimer(
               "Loading a single key from git file diff cache",
@@ -177,7 +185,8 @@
 
     @Override
     public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
-        Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
+        Iterable<? extends GitFileDiffCacheKey> keys)
+        throws IOException, DiffNotAvailableException {
       try (TraceTimer timer =
           TraceContext.newTimer("Loading multiple keys from git file diff cache")) {
         ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
@@ -215,13 +224,14 @@
      */
     private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
         Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
-        throws IOException {
+        throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
           ImmutableMap.builderWithExpectedSize(keys.size());
       Map<GitFileDiffCacheKey, String> filePaths =
           keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
       DiffFormatter formatter = createDiffFormatter(options, repo, reader);
-      Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
+      ListMultimap<String, DiffEntry> diffEntries =
+          loadDiffEntries(formatter, options, filePaths.values());
       for (GitFileDiffCacheKey key : filePaths.keySet()) {
         String newFilePath = filePaths.get(key);
         if (!diffEntries.containsKey(newFilePath)) {
@@ -233,14 +243,25 @@
                   newFilePath));
           continue;
         }
-        DiffEntry diffEntry = diffEntries.get(newFilePath);
-        GitFileDiff gitFileDiff = createGitFileDiff(diffEntry, formatter, key);
-        result.put(key, gitFileDiff);
+        List<DiffEntry> entries = diffEntries.get(newFilePath);
+        if (entries.size() == 1) {
+          result.put(key, createGitFileDiff(entries.get(0), formatter, key));
+        } else {
+          // Handle when JGit returns two {Added, Deleted} entries for the same file. This happens,
+          // for example, when a file's mode is changed between patchsets (e.g. converting a
+          // symlink to a regular file). We combine both diff entries into a single entry with
+          // {changeType = Rewrite}.
+          List<GitFileDiff> gitDiffs = new ArrayList<>();
+          for (DiffEntry entry : diffEntries.get(newFilePath)) {
+            gitDiffs.add(createGitFileDiff(entry, formatter, key));
+          }
+          result.put(key, createRewriteEntry(gitDiffs));
+        }
       }
       return result.build();
     }
 
-    private static Map<String, DiffEntry> loadDiffEntries(
+    private static ListMultimap<String, DiffEntry> loadDiffEntries(
         DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
         throws IOException {
       Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
@@ -251,7 +272,11 @@
 
       return diffEntries.stream()
           .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
-          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+          .collect(
+              Multimaps.toMultimap(
+                  d -> pathExtractor.apply(d),
+                  identity(),
+                  MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
     private static DiffFormatter createDiffFormatter(
@@ -334,6 +359,39 @@
     }
   }
 
+  /**
+   * Create a single {@link GitFileDiff} with {@link Patch.ChangeType} equals {@link
+   * Patch.ChangeType#REWRITE}, assuming the input list contains two entries with types {@link
+   * Patch.ChangeType#ADDED} and {@link Patch.ChangeType#DELETED}.
+   *
+   * @param gitDiffs input list of exactly two {@link GitFileDiff} for same file path.
+   * @return a single {@link GitFileDiff} with change type equals {@link Patch.ChangeType#REWRITE}.
+   * @throws DiffNotAvailableException if input list contains git diffs with change types other than
+   *     {ADDED, DELETED}. This is a JGit error.
+   */
+  private static GitFileDiff createRewriteEntry(List<GitFileDiff> gitDiffs)
+      throws DiffNotAvailableException {
+    if (gitDiffs.size() != 2) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "JGit error: found %d dff entries for same file path %s",
+              gitDiffs.size(), gitDiffs.get(0).getDefaultPath()));
+    }
+    if (!ImmutableSet.of(gitDiffs.get(0).changeType(), gitDiffs.get(1).changeType())
+        .equals(ADDED_AND_DELETED)) {
+      // This is an illegal state. JGit is not supposed to return this, so we throw an exception.
+      throw new DiffNotAvailableException(
+          String.format(
+              "JGit error: unexpected change types %s and %s for same file path %s",
+              gitDiffs.get(0).changeType(),
+              gitDiffs.get(1).changeType(),
+              gitDiffs.get(0).getDefaultPath()));
+    }
+    GitFileDiff addedEntry =
+        gitDiffs.get(0).changeType() == Patch.ChangeType.ADDED ? gitDiffs.get(0) : gitDiffs.get(1);
+    return addedEntry.toBuilder().changeType(Patch.ChangeType.REWRITE).build();
+  }
+
   /** An entity representing the options affecting the diff computation. */
   @AutoValue
   abstract static class DiffOptions {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 65d9d9e..e66b974 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
@@ -26,16 +29,20 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.Map;
 import java.util.Optional;
 
 /** Evaluates submit requirements for different change data. */
 @Singleton
 public class SubmitRequirementsEvaluator {
   private final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
+  private final ProjectCache projectCache;
 
   @Inject
-  private SubmitRequirementsEvaluator(Provider<ChangeQueryBuilder> changeQueryBuilderProvider) {
+  private SubmitRequirementsEvaluator(
+      Provider<ChangeQueryBuilder> changeQueryBuilderProvider, ProjectCache projectCache) {
     this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+    this.projectCache = projectCache;
   }
 
   /**
@@ -50,6 +57,21 @@
     changeQueryBuilderProvider.get().parse(expression.expressionString());
   }
 
+  /**
+   * Evaluate and return all submit requirements for a change. Submit requirements are retrieved for
+   * the project containing the change and parent projects as well.
+   */
+  public Map<SubmitRequirement, SubmitRequirementResult> getResults(ChangeData cd) {
+    ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
+    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
+        ImmutableMap.builderWithExpectedSize(requirements.size());
+    for (SubmitRequirement requirement : requirements.values()) {
+      result.put(requirement, evaluate(requirement, cd));
+    }
+    return result.build();
+  }
+
   /** Evaluate a {@link SubmitRequirement} on a given {@link ChangeData}. */
   public SubmitRequirementResult evaluate(SubmitRequirement sr, ChangeData cd) {
     SubmitRequirementExpressionResult blockingResult =
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 4c2c7e8..3bf072a 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -44,11 +44,12 @@
         "approval and target must be the same change. got: %s, %s",
         psa.patchSetId(),
         id);
-    checkState(
-        psa.patchSetId().get() + 1 == id.get(),
-        "approvals can only be copied to the next consecutive patch set. got: %s, %s",
-        psa.patchSetId(),
-        id);
+    // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
+    // it's ensured that approvals are only copied to the next consecutive patch set. To add back
+    // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
+    // As explained in the commit message of this change doing this check is only possible if there
+    // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
+    // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
     return new AutoValue_ApprovalContext(psa, id, changeNotes, changeKind);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
index 2924e6e..326620d 100644
--- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -23,6 +23,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Objects;
+import java.util.Optional;
 
 /** Predicate that matches patch set approvals we want to copy based on the value. */
 public class MagicValuePredicate extends ApprovalPredicate {
@@ -47,19 +48,23 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
+    Optional<LabelType> lt =
+        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
     short pValue;
     switch (value) {
       case ANY:
         return true;
       case MIN:
-        pValue =
-            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
-                .getMaxNegative();
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxNegative();
         break;
       case MAX:
-        pValue =
-            getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId())
-                .getMaxPositive();
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxPositive();
         break;
       default:
         throw new IllegalArgumentException("unrecognized label value: " + value);
@@ -67,7 +72,7 @@
     return pValue == ctx.patchSetApproval().value();
   }
 
-  private LabelType getLabelType(Project.NameKey project, LabelId labelId) {
+  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
     return projectCache
         .get(project)
         .orElseThrow(() -> new IllegalStateException(project + " absent"))
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index f912250..852387f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -106,6 +106,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -932,21 +933,36 @@
     return messages;
   }
 
-  /** Get all submit requirements for this change, including those from parent projects. */
+  /**
+   * Get all evaluated submit requirements for this change, including those from parent projects.
+   * For closed changes, submit requirements are read from the change notes. For active changes,
+   * submit requirements are evaluated online.
+   *
+   * <p>For changes loaded from the index, the value will be set from index field {@link
+   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+   */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
     if (submitRequirements == null) {
-      ProjectState state = projectCache.get(project()).orElseThrow(illegalState(project()));
-      Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
-      ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
-          ImmutableMap.builderWithExpectedSize(requirements.size());
-      for (SubmitRequirement requirement : requirements.values()) {
-        result.put(requirement, submitRequirementsEvaluator.evaluate(requirement, this));
+      if (!lazyload()) {
+        return Collections.emptyMap();
       }
-      submitRequirements = result.build();
+      Change c = change();
+      if (c != null && c.isClosed()) {
+        submitRequirements =
+            notes().getSubmitRequirementsResult().stream()
+                .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
+      } else {
+        submitRequirements = submitRequirementsEvaluator.getResults(this);
+      }
     }
     return submitRequirements;
   }
 
+  public void setSubmitRequirements(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
+    this.submitRequirements = submitRequirements;
+  }
+
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
     // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
     // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 30d5e2f..ade615c 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -91,8 +91,8 @@
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
-    if (types.byLabel(toFind) != null) {
-      return types.byLabel(toFind);
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
     }
 
     for (LabelType lt : types.getLabelTypes()) {
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index e3c58e47..2c56322 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -87,8 +87,8 @@
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
-    if (types.byLabel(toFind) != null) {
-      return types.byLabel(toFind);
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
     }
 
     for (LabelType lt : types.getLabelTypes()) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 84424a8..2c358d0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -194,7 +194,7 @@
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.labelId()) == null) {
+        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
           continue; // Ignore undefined labels.
         } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 1efe378..320e57d 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -183,7 +183,7 @@
         r =
             Response.ok(
                 fileInfoJson.getFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum));
       } else {
         r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -280,11 +280,11 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ null);
+                project, patchSet.commitId(), /* parentNum= */ 0);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ null);
+                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 2169d57..dd951a8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -74,6 +74,7 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  /** 1-based index of the parent's position in the commit object. */
   @Option(name = "--parent", metaVar = "parent-number")
   int parentNum;
 
@@ -143,7 +144,7 @@
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum, pId, prefs, currentUser.get());
     } else {
       psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 9263971..8c21841 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -64,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -265,11 +266,13 @@
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
+        Optional<LabelType> type =
+            projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
-        if (type == null || (type.isMaxNegative(psa) && type.getFunction().isBlock())) {
+        if (!type.isPresent()
+            || (type.get().isMaxNegative(psa) && type.get().getFunction().isBlock())) {
           continue;
         }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 6816361..4dbb6ee 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -22,7 +22,6 @@
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -502,8 +501,8 @@
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = labelTypes.byLabel(ent.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(ent.getKey());
+      if (!type.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -518,15 +517,15 @@
         logger.atFine().log(
             "skipping on behalf of permission check for label %s"
                 + " because caller is an internal user",
-            type.getName());
+            type.get().getName());
       } else {
         try {
-          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type.get(), ent.getValue()));
         } catch (AuthException e) {
           throw new AuthException(
               String.format(
                   "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                  type.getName(), in.onBehalfOf),
+                  type.get().getName(), in.onBehalfOf),
               e);
         }
       }
@@ -558,8 +557,8 @@
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType lt = labelTypes.byLabel(ent.getKey());
-      if (lt == null) {
+      Optional<LabelType> lt = labelTypes.byLabel(ent.getKey());
+      if (!lt.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -576,7 +575,7 @@
         continue;
       }
 
-      if (lt.getValue(ent.getValue()) == null) {
+      if (lt.get().getValue(ent.getValue()) == null) {
         logger.atFine().log("label value %s not found", ent.getValue());
         if (strictLabels) {
           throw new BadRequestException(
@@ -590,10 +589,10 @@
 
       short val = ent.getValue();
       try {
-        perm.check(new LabelPermission.WithValue(lt, val));
+        perm.check(new LabelPermission.WithValue(lt.get(), val));
       } catch (AuthException e) {
         throw new AuthException(
-            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val), e);
+            String.format("Applying label \"%s\": %d is restricted", lt.get().getName(), val), e);
       }
     }
   }
@@ -1356,7 +1355,10 @@
       ChangeUpdate update = ctx.getUpdate(psId);
       for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
-        LabelType lt = requireNonNull(labelTypes.byLabel(name), name);
+        LabelType lt =
+            labelTypes
+                .byLabel(name)
+                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
@@ -1448,7 +1450,10 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1460,7 +1465,10 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1508,9 +1516,9 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
+        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+        if (lt.isPresent()) {
+          current.put(lt.get().getName(), a);
         } else {
           del.add(a);
         }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 7bee2f2..6d054bd 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -77,6 +77,10 @@
   }
 
   public static final class ListFiles implements RestReadView<CommitResource> {
+    /**
+     * The 1-based parent number. If zero, the default base commit will be used, which is the only
+     * parent for commits having one parent or the auto-merge commit otherwise.
+     */
     @Option(name = "--parent", metaVar = "parent-number")
     int parentNum;
 
@@ -97,8 +101,7 @@
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       return Response.ok(
-          fileInfoJson.getFileInfoMap(
-              resource.getProjectState().getNameKey(), commit, parentNum - 1));
+          fileInfoJson.getFileInfoMap(resource.getProjectState().getNameKey(), commit, parentNum));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 363cdca..942f024 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -241,6 +241,7 @@
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -274,7 +275,8 @@
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
-      ChangeData.Factory changeDataFactory) {
+      ChangeData.Factory changeDataFactory,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -291,6 +293,7 @@
     this.topicMetrics = topicMetrics;
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   @Override
@@ -665,7 +668,7 @@
         Change.Id changeId = entry.getKey();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, new StoreSubmitRequirementsOp(changeDataFactory));
+            .addOp(changeId, storeSubmitRequirementsOpFactory.create());
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index 26c8ac9..dfbbf81 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -332,6 +332,8 @@
         MergeOpRepoManager orm)
         throws IOException {
       Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      if (RefNames.isGerritRef(srcBranch.branch())) return ret;
+
       Project.NameKey srcProject = srcBranch.project();
       for (SubscribeSection s :
           projectCache
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 0668c1e..f3bd5e1 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index c94b25c..e58040f 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.base.Throwables;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -27,6 +29,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.Optional;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.eclipse.jgit.lib.Config;
@@ -61,6 +64,20 @@
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
               requestListeners.runEach(l -> l.onRequest(requestInfo));
               SshCommand.this.run();
+            } catch (RuntimeException e) {
+              Optional<RequestCancelledException> requestCancelledException =
+                  RequestCancelledException.getFromCausalChain(e);
+              if (!requestCancelledException.isPresent()) {
+                Throwables.throwIfUnchecked(e);
+              }
+              StringBuilder msg =
+                  new StringBuilder(requestCancelledException.get().formatCancellationReason());
+              if (requestCancelledException.get().getCancellationMessage().isPresent()) {
+                msg.append(
+                    String.format(
+                        " (%s)", requestCancelledException.get().getCancellationMessage().get()));
+              }
+              stderr.println(msg.toString());
             } finally {
               stdout.flush();
               stderr.flush();
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index f28f60f..12da2c1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -62,6 +63,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -132,7 +134,6 @@
     cfg.setString("user", null, "name", "Gerrit Code Review");
     cfg.setString("user", null, "email", "gerrit@localhost");
     cfg.unset("cache", null, "directory");
-    cfg.setString("index", null, "type", "lucene");
     cfg.setBoolean("index", "lucene", "testInmemory", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
     cfg.setBoolean("receive", null, "enableSignedPush", false);
@@ -195,6 +196,7 @@
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
@@ -237,7 +239,8 @@
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    IndexType indexType = new IndexType(cfg.getString("index", null, "type"));
+    String indexTypeCfg = cfg.getString("index", null, "type");
+    IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
     // For custom index types, callers must provide their own module.
     if (indexType.isLucene()) {
       install(luceneIndexModule());
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index daef2b3..d68dcad 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -38,7 +38,9 @@
   }
 
   public static Config createForLucene() {
-    return create();
+    Config cfg = create();
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
   }
 
   public static Config createForElasticsearch() {
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 5ee292ff..9a656b8 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -16,6 +16,7 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.util.Optional;
 
 /** Exports list of {@code commit_label( label('Code-Review', 2), user(12345789) )}. */
 class PRED__load_commit_labels_1 extends Predicate.P1 {
@@ -38,13 +39,14 @@
     LabelTypes types = cd.getLabelTypes();
 
     for (PatchSetApproval a : cd.currentApprovals()) {
-      LabelType t = types.byLabel(a.labelId());
-      if (t == null) {
+      Optional<LabelType> t = types.byLabel(a.labelId());
+      if (!t.isPresent()) {
         continue;
       }
 
       StructureTerm labelTerm =
-          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
+          new StructureTerm(
+              sym_label, SymbolTerm.intern(t.get().getName()), new IntegerTerm(a.value()));
 
       StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 9e51054..d54574a 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -2975,7 +2975,7 @@
   }
 
   @Test
-  public void externalIdBatchUpdates_commitMsg() throws Exception {
+  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
     ExternalId extId1 =
         ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
@@ -2998,6 +2998,22 @@
     }
   }
 
+  @Test
+  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
+    ExternalId extId =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+
+    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
+    }
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index f66bc8d..aa8615b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -75,6 +75,7 @@
     i.emailStrategy = EmailStrategy.DISABLED;
     i.emailFormat = EmailFormat.PLAINTEXT;
     i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
+    i.disableKeyboardShortcuts = true;
     i.expandInlineDiffs ^= true;
     i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
@@ -93,6 +94,7 @@
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
+    assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c08aa7f..7785776 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4325,6 +4325,101 @@
   }
 
   @Test
+  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.UNSATISFIED);
+
+    voteLabel(changeId, "code-review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+
+    gApi.changes().id(changeId).current().submit();
+
+    // Add new submit requirement
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // The new "verified" submit requirement is not returned, since this change is closed
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(change.submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+    gApi.changes().id(changeId).current().submit();
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements, "code-review", Status.SATISFIED);
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index d8dab33..5f3b702 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.server.change.FileInfoJsonExperimentImpl;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
@@ -109,7 +110,8 @@
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
     useNewDiffCacheListFiles =
-        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
+        Arrays.asList(baseConfig.getStringList("experiments", null, "enabled"))
+            .contains(FileInfoJsonExperimentImpl.NEW_DIFF_CACHE_FEATURE);
     useNewDiffCacheGetDiff =
         baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
@@ -153,7 +155,8 @@
     PushOneCommit.Result result = push.to("refs/heads/master");
 
     Map<String, FileDiffOutput> modifiedFiles =
-        diffOperations.listModifiedFilesAgainstParent(project, result.getCommit(), null);
+        diffOperations.listModifiedFilesAgainstParent(
+            project, result.getCommit(), /* parentNum= */ 0);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
@@ -2835,11 +2838,7 @@
   }
 
   @Test
-  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
-    // TODO(ghareeb): fix this test for the new diff cache implementation
-    assume().that(useNewDiffCacheListFiles).isFalse();
-    assume().that(useNewDiffCacheGetDiff).isFalse();
-
+  public void symlinkConvertedToRegularFileIsIdentifiedAsRewritten() throws Exception {
     String target = "file.txt";
     String symlink = "link.lnk";
 
@@ -2867,23 +2866,39 @@
         gApi.changes().id(result.getChangeId()).current().files(initialRev);
 
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
-    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+
+    // Both old and new diff caches agree that the state is rewritten
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewritten
 
     DiffInfo diffInfo =
         gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);
 
-    // The diff logic identifies two entries for the file:
-    // 1. One entry as 'DELETED' for the symlink.
-    // 2. Another entry as 'ADDED' for the new regular file.
-    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
-    // this case so that the client is able to see the new content that was added to the file.
-    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diffInfo.content).hasSize(1);
-    assertThat(diffInfo)
-        .content()
-        .element(0)
-        .linesOfB()
-        .containsExactly("Content of the new file named 'symlink'");
+    // TODO(ghareeb): Remove the else branch when the new diff cache is rolled out as default.
+    if (useNewDiffCacheGetDiff) {
+      // File diff in New diff cache: change type is correctly identified as REWRITTEN
+      assertThat(diffInfo.changeType).isEqualTo(ChangeType.REWRITE);
+      assertThat(diffInfo.content).hasSize(2);
+      assertThat(diffInfo)
+          .content()
+          .element(0)
+          .linesOfB()
+          .containsExactly("Content of the new file named 'symlink'");
+      assertThat(diffInfo).content().element(1).linesOfA().containsExactly("file.txt");
+    } else {
+      // File diff in old diff cache: The diff logic identifies two entries for the file:
+      // 1. One entry as 'DELETED' for the symlink.
+      // 2. Another entry as 'ADDED' for the new regular file.
+      // Since the diff logic returns a single entry, the implementation prioritizes  the 'ADDED'
+      // entry in this case so that the user is able to see the new content that was added to the
+      // file.
+      assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+      assertThat(diffInfo.content).hasSize(1);
+      assertThat(diffInfo)
+          .content()
+          .element(0)
+          .linesOfB()
+          .containsExactly("Content of the new file named 'symlink'");
+    }
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
index ec0bcc6..714bd78 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.revision;
 
+import com.google.gerrit.server.change.FileInfoJsonExperimentImpl;
 import com.google.gerrit.testing.ConfigSuite;
 import org.eclipse.jgit.lib.Config;
 
@@ -26,7 +27,8 @@
   @ConfigSuite.Default
   public static Config newDiffCacheConfig() {
     Config config = new Config();
-    config.setBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", true);
+    config.setString(
+        "experiments", null, "enabled", FileInfoJsonExperimentImpl.NEW_DIFF_CACHE_FEATURE);
     return config;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
index 84887da..a62d551 100644
--- a/javatests/com/google/gerrit/acceptance/rest/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -5,6 +5,7 @@
     group = "rest_bindings_collection",
     labels = ["rest"],
     deps = [
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
new file mode 100644
index 0000000..0713fe6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -0,0 +1,248 @@
+// 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.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class CancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us to verify the HTTP status code that is
+            // set when a request is cancelled.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CLIENT_CLOSED_REQUEST);
+      assertThat(response.getEntityContent()).isEqualTo("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledException() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleClientDisconnectedForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us verify the error message that is sent
+            // to the client.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledExceptionForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessageForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index 5634322..ee1b221 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -18,6 +18,7 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
         "//lib/commons:compress",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
new file mode 100644
index 0000000..3e31c16
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -0,0 +1,120 @@
+// 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.ssh;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@UseSsh
+public class SshCancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledException() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 7ab7ae9..a2825322 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -55,6 +55,7 @@
         "//java/com/google/gerrit/server/account/externalids/testing",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/fixes/testing",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
diff --git a/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
new file mode 100644
index 0000000..e2c43eb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
@@ -0,0 +1,178 @@
+// 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.cancellation;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.Test;
+
+public class RequestStateContextTest {
+  @Test
+  public void openContext() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void openNestedContexts() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+
+      RequestStateProvider requestStateProvider3 = new TestRequestStateProvider();
+      try (RequestStateContext requestStateContext2 =
+          RequestStateContext.open().addRequestStateProvider(requestStateProvider3)) {
+        RequestStateProvider requestStateProvider4 = new TestRequestStateProvider();
+        requestStateContext2.addRequestStateProvider(requestStateProvider4);
+        assertRequestStateProviders(
+            ImmutableSet.of(
+                requestStateProvider1,
+                requestStateProvider2,
+                requestStateProvider3,
+                requestStateProvider4));
+      }
+
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void openNestedContextsWithSameRequestStateProviders() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+
+      try (RequestStateContext requestStateContext2 =
+          RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+        requestStateContext2.addRequestStateProvider(requestStateProvider2);
+
+        assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+      }
+
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void abortIfCancelled_noRequestStateProvider() {
+    assertNoRequestStateProviders();
+
+    // Calling abortIfCancelled() shouldn't throw an exception.
+    RequestStateContext.abortIfCancelled();
+  }
+
+  @Test
+  public void abortIfCancelled_requestNotCancelled() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {}
+                })) {
+      // Calling abortIfCancelled() shouldn't throw an exception.
+      RequestStateContext.abortIfCancelled();
+    }
+  }
+
+  @Test
+  public void abortIfCancelled_requestCancelled() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null);
+                  }
+                })) {
+      RequestCancelledException requestCancelledException =
+          assertThrows(
+              RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      assertThat(requestCancelledException)
+          .hasMessageThat()
+          .isEqualTo("Request cancelled: CLIENT_CLOSED_REQUEST");
+      assertThat(requestCancelledException.getCancellationReason())
+          .isEqualTo(RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST);
+      assertThat(requestCancelledException.getCancellationMessage()).isEmpty();
+    }
+  }
+
+  @Test
+  public void abortIfCancelled_requestCancelled_withMessage() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+                  }
+                })) {
+      RequestCancelledException requestCancelledException =
+          assertThrows(
+              RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      assertThat(requestCancelledException)
+          .hasMessageThat()
+          .isEqualTo("Request cancelled: SERVER_DEADLINE_EXCEEDED (deadline = 10m)");
+      assertThat(requestCancelledException.getCancellationReason())
+          .isEqualTo(RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED);
+      assertThat(requestCancelledException.getCancellationMessage()).hasValue("deadline = 10m");
+    }
+  }
+
+  private void assertNoRequestStateProviders() {
+    assertRequestStateProviders(ImmutableSet.of());
+  }
+
+  private void assertRequestStateProviders(
+      ImmutableSet<RequestStateProvider> expectedRequestStateProviders) {
+    assertThat(RequestStateContext.getRequestStateProviders())
+        .containsExactlyElementsIn(expectedRequestStateProviders);
+  }
+
+  private static class TestRequestStateProvider implements RequestStateProvider {
+    @Override
+    public void checkIfCancelled(OnCancelled onCancelled) {}
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 5bf5154..aa313e3 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -72,7 +72,7 @@
 
     FileDiffOutput diffOutput =
         diffOperations.getModifiedFileAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+            testProjectName, newCommitId, /* parentNum=*/ 0, fileName2, /* whitespace=*/ null);
 
     assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
     assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index fc6b412..9cba362 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -72,7 +72,7 @@
 
   @Test
   public void createSchema_Label_CodeReview() throws Exception {
-    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
+    LabelType codeReview = getLabelTypes().byLabel("Code-Review").get();
     assertThat(codeReview).isNotNull();
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
diff --git a/plugins/replication b/plugins/replication
index dc9bb2e..46cfb7d 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit dc9bb2e946e4c6c31e8a4665f30eca6d00017523
+Subproject commit 46cfb7dd5b6891f991cfe66e72c08953487c1c81
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 35e6449..a28ae59 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 35e6449a517691a880c94e7467bc07360f8e6666
+Subproject commit a28ae590486934690e4e0a95d7eb75f8b60644a6
diff --git a/plugins/webhooks b/plugins/webhooks
index 9fc9c2d..73f9dc7 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 9fc9c2d4e69f7e2701cbcd873977d3312a231a81
+Subproject commit 73f9dc72bd52f5d64853db31e711717a995f0a46
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index fcf1cf4..5f9c3c5 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -106,10 +106,6 @@
     "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
     "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
     "elements/change/gr-thread-list/gr-thread-list_html.ts",
-    "elements/checks/gr-hovercard-run_html.ts",
-    "elements/core/gr-main-header/gr-main-header_html.ts",
-    "elements/core/gr-search-bar/gr-search-bar_html.ts",
-    "elements/core/gr-smart-search/gr-smart-search_html.ts",
     "elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts",
     "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
     "elements/diff/gr-diff-host/gr-diff-host_html.ts",
@@ -123,17 +119,14 @@
     "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
     "elements/shared/gr-account-list/gr-account-list_html.ts",
     "elements/shared/gr-autocomplete/gr-autocomplete_html.ts",
-    "elements/shared/gr-change-status/gr-change-status_html.ts",
     "elements/shared/gr-comment-thread/gr-comment-thread_html.ts",
     "elements/shared/gr-comment/gr-comment_html.ts",
     "elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts",
-    "elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts",
     "elements/shared/gr-dialog/gr-dialog_html.ts",
     "elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts",
     "elements/shared/gr-download-commands/gr-download-commands_html.ts",
     "elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts",
     "elements/shared/gr-dropdown/gr-dropdown_html.ts",
-    "elements/shared/gr-editable-content/gr-editable-content_html.ts",
     "elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts",
     "elements/shared/gr-label-info/gr-label-info_html.ts",
     "elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts",
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 7400295..4b20072 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -213,6 +213,10 @@
   automatic_blink?: boolean;
 }
 
+export declare type DiffResponsiveMode =
+  | 'FULL_RESPONSIVE'
+  | 'SHRINK_ONLY'
+  | 'NONE';
 export declare interface RenderPreferences {
   hide_left_side?: boolean;
   disable_context_control_buttons?: boolean;
@@ -220,6 +224,7 @@
   hide_line_length_indicator?: boolean;
   use_block_expansion?: boolean;
   image_diff_prefs?: ImageDiffPreferences;
+  responsive_mode?: DiffResponsiveMode;
 }
 
 /**
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 a0b1794..fce7db9 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
@@ -1486,6 +1486,7 @@
   }
 
   _handleDeleteConfirm() {
+    this._hideAllDialogs();
     this._fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
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 af565cb..a090aee 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
@@ -150,18 +150,25 @@
     this._query = (text: string) => this._getProjectBranchesSuggestions(text);
   }
 
+  containsDuplicateProject(changes: ChangeInfo[]) {
+    const projects: {[projectName: string]: boolean} = {};
+    for (let i = 0; i < changes.length; i++) {
+      const change = changes[i];
+      if (projects[change.project]) {
+        return true;
+      }
+      projects[change.project] = true;
+    }
+    return false;
+  }
+
   updateChanges(changes: ChangeInfo[]) {
     this.changes = changes;
     this._statuses = {};
-    const projects: {[projectName: string]: boolean} = {};
-    this._duplicateProjectChanges = false;
     changes.forEach(change => {
       this.selectedChangeIds.add(change.id);
-      if (projects[change.project]) {
-        this._duplicateProjectChanges = true;
-      }
-      projects[change.project] = true;
     });
+    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
     this._changesCount = changes.length;
     this._showCherryPickTopic = changes.length > 1;
   }
@@ -185,6 +192,10 @@
     if (this.selectedChangeIds.has(changeId))
       this.selectedChangeIds.delete(changeId);
     else this.selectedChangeIds.add(changeId);
+    const changes = this.changes.filter(change =>
+      this.selectedChangeIds.has(change.id)
+    );
+    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
   }
 
   _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index 536a4ab..1034674 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -130,6 +130,8 @@
     });
 
     test('deselecting a change removes it from being cherry picked', () => {
+      const duplicateChangesStub = sinon.stub(element,
+          'containsDuplicateProject');
       element.branch = 'master';
       const executeChangeActionStub = stubRestApi(
           'executeChangeAction').returns(Promise.resolve([]));
@@ -142,6 +144,7 @@
           querySelector('gr-dialog').$.confirm);
       flush();
       assert.equal(executeChangeActionStub.callCount, 1);
+      assert.isTrue(duplicateChangesStub.called);
     });
 
     test('deselecting all change shows error message', () => {
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 9dcb67e..5206fdb 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
@@ -952,7 +952,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.next();
+      this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
@@ -972,7 +972,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.previous();
+      this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
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 0655721..79bc9f6 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
@@ -533,7 +533,7 @@
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
         assert.equal(element.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -548,8 +548,8 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
 
         const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index bd4cb76..8201dbc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -487,8 +487,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -558,8 +558,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hide-hovercard=""
-              selection-chip-style
+              hideHovercard
+              selectionChipStyle
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -573,8 +573,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -593,8 +593,8 @@
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -614,8 +614,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index 73f3dd3..93a432b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -109,7 +109,7 @@
           <gr-account-label
             account="[[item]]"
             on-click="handleAccountClicked"
-            selection-chip-style
+            selectionChipStyle
             selected="[[isSelected(item, selectedAuthors)]]"
           > </gr-account-label>
         </template>
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 1ae3e2b..2316a05 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -112,6 +112,10 @@
     if (!run) return true;
     return !run.checkLink && !run.checkDescription;
   }
+
+  _convertUndefined(value?: string) {
+    return value ?? '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
index 277bd16..5b2e24a 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -130,7 +130,7 @@
         <div hidden$="[[!run.statusLink]]" class="row">
           <div class="title">Status</div>
           <div>
-            <a href="[[run.statusLink]]" target="_blank"
+            <a href="[[_convertUndefined(run.statusLink)]]" target="_blank"
               ><iron-icon
                 aria-label="external link to check status"
                 class="small link"
@@ -202,7 +202,7 @@
         <div hidden$="[[!run.checkLink]]" class="row">
           <div class="title">Documentation</div>
           <div>
-            <a href="[[run.checkLink]]" target="_blank"
+            <a href="[[_convertUndefined(run.checkLink)]]" target="_blank"
               ><iron-icon
                 aria-label="external link to check documentation"
                 class="small link"
@@ -216,10 +216,7 @@
     </div>
     <template is="dom-repeat" items="[[computeActions(run)]]">
       <div class="action">
-        <gr-checks-action
-          event-target="[[_target]]"
-          action="[[item]]"
-        ></gr-checks-action>
+        <gr-checks-action action="[[item]]"></gr-checks-action>
       </div>
     </template>
   </div>
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 f7e61bf..fd7e25e 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
@@ -110,7 +110,7 @@
   }
 
   @property({type: String, notify: true})
-  searchQuery?: string;
+  searchQuery = '';
 
   @property({type: Boolean, reflectToAttribute: true})
   loggedIn?: boolean;
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 e30e75e..841089e 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
@@ -180,7 +180,7 @@
   accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
 
   @property({type: String})
-  _inputVal?: string;
+  _inputVal = '';
 
   @property({type: Number})
   _threshold = 1;
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.ts
similarity index 66%
rename from polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
rename to polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index ae7e10c..71d378e 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.ts
@@ -15,17 +15,28 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-search-bar.js';
-import '../../../scripts/util.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 '../../../test/common-test-setup-karma';
+import './gr-search-bar';
+import '../../../scripts/util';
+import {GrSearchBar} from './gr-search-bar';
+import {
+  TestKeyboardShortcutBinder,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createChangeConfig,
+  createGerritInfo,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {MergeabilityComputationBehavior} from '../../../constants/constants';
 
 const basicFixture = fixtureFromElement('gr-search-bar');
 
 suite('gr-search-bar tests', () => {
-  let element;
+  let element: GrSearchBar;
 
   suiteSetup(() => {
     const kb = TestKeyboardShortcutBinder.push();
@@ -46,26 +57,34 @@
     assert.equal(element._inputVal, 'foo');
   });
 
-  const getActiveElement = () => (document.activeElement.shadowRoot ?
-    document.activeElement.shadowRoot.activeElement :
-    document.activeElement);
+  const getActiveElement = () =>
+    document.activeElement!.shadowRoot
+      ? document.activeElement!.shadowRoot.activeElement
+      : document.activeElement;
 
   test('enter in search input fires event', done => {
     element.addEventListener('handle-search', () => {
       assert.notEqual(getActiveElement(), element.$.searchInput);
-      assert.notEqual(getActiveElement(), element.$.searchButton);
       done();
     });
     element.value = 'test';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
   });
 
   test('input blurred after commit', () => {
     const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
     element.$.searchInput.text = 'fate/stay';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isTrue(blurSpy.called);
   });
 
@@ -73,8 +92,12 @@
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = '';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isFalse(searchSpy.called);
   });
 
@@ -82,8 +105,12 @@
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'added:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isFalse(searchSpy.called);
   });
 
@@ -91,8 +118,12 @@
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'age:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isTrue(searchSpy.called);
   });
 
@@ -100,8 +131,12 @@
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isTrue(searchSpy.called);
   });
 
@@ -109,8 +144,12 @@
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
     assert.isTrue(searchSpy.called);
   });
 
@@ -129,21 +168,23 @@
     });
 
     test('Autocompletes accounts', () => {
-      sinon.stub(element, 'accountSuggestions').callsFake(() =>
-        Promise.resolve([{text: 'owner:fred@goog.co'}])
-      );
+      sinon
+        .stub(element, 'accountSuggestions')
+        .callsFake(() => Promise.resolve([{text: 'owner:fred@goog.co'}]));
       return element._getSearchSuggestions('owner:fr').then(s => {
         assert.equal(s[0].value, 'owner:fred@goog.co');
       });
     });
 
     test('Autocompletes groups', done => {
-      sinon.stub(element, 'groupSuggestions').callsFake(() =>
-        Promise.resolve([
-          {text: 'ownerin:Polygerrit'},
-          {text: 'ownerin:gerrit'},
-        ])
-      );
+      sinon
+        .stub(element, 'groupSuggestions')
+        .callsFake(() =>
+          Promise.resolve([
+            {text: 'ownerin:Polygerrit'},
+            {text: 'ownerin:gerrit'},
+          ])
+        );
       element._getSearchSuggestions('ownerin:pol').then(s => {
         assert.equal(s[0].value, 'ownerin:Polygerrit');
         done();
@@ -151,13 +192,15 @@
     });
 
     test('Autocompletes projects', done => {
-      sinon.stub(element, 'projectSuggestions').callsFake(() =>
-        Promise.resolve([
-          {text: 'project:Polygerrit'},
-          {text: 'project:gerrit'},
-          {text: 'project:gerrittest'},
-        ])
-      );
+      sinon
+        .stub(element, 'projectSuggestions')
+        .callsFake(() =>
+          Promise.resolve([
+            {text: 'project:Polygerrit'},
+            {text: 'project:gerrit'},
+            {text: 'project:gerrittest'},
+          ])
+        );
       element._getSearchSuggestions('project:pol').then(s => {
         assert.equal(s[0].value, 'project:Polygerrit');
         done();
@@ -193,11 +236,15 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(done => {
-        stubRestApi('getConfig').returns(Promise.resolve({
-          change: {
-            mergeability_computation_behavior: mergeability,
-          },
-        }));
+        stubRestApi('getConfig').returns(
+          Promise.resolve({
+            ...createServerInfo(),
+            change: {
+              ...createChangeConfig(),
+              mergeability_computation_behavior: mergeability as MergeabilityComputationBehavior,
+            },
+          })
+        );
 
         element = basicFixture.instantiate();
         flush(done);
@@ -218,11 +265,15 @@
 
   suite('doc url', () => {
     setup(done => {
-      stubRestApi('getConfig').returns(Promise.resolve({
-        gerrit: {
-          doc_url: 'https://doc.com/',
-        },
-      }));
+      stubRestApi('getConfig').returns(
+        Promise.resolve({
+          ...createServerInfo(),
+          gerrit: {
+            ...createGerritInfo(),
+            doc_url: 'https://doc.com/',
+          },
+        })
+      );
 
       _testOnly_clearDocsBaseUrlCache();
       element = basicFixture.instantiate();
@@ -232,18 +283,17 @@
     test('compute help doc url with correct path', () => {
       assert.equal(element.docBaseUrl, 'https://doc.com/');
       assert.equal(
-          element._computeHelpDocLink(element.docBaseUrl),
-          'https://doc.com/user-search.html'
+        element._computeHelpDocLink(element.docBaseUrl),
+        'https://doc.com/user-search.html'
       );
     });
 
     test('compute help doc url fallback to gerrit url', () => {
       assert.equal(
-          element._computeHelpDocLink(),
-          'https://gerrit-review.googlesource.com/documentation/' +
+        element._computeHelpDocLink(null),
+        'https://gerrit-review.googlesource.com/documentation/' +
           'user-search.html'
       );
     });
   });
 });
-
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 aa7e2e0..7419713 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
@@ -32,6 +32,12 @@
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
+declare global {
+  interface HTMLElementEventMap {
+    'handle-search': CustomEvent<SearchBarHandleSearchDetail>;
+  }
+}
+
 @customElement('gr-smart-search')
 export class GrSmartSearch extends PolymerElement {
   static get template() {
@@ -39,7 +45,7 @@
   }
 
   @property({type: String})
-  searchQuery?: string;
+  searchQuery = '';
 
   @property({type: Object})
   _config?: ServerInfo;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
deleted file mode 100644
index f3a9965..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
+++ /dev/null
@@ -1,137 +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-smart-search.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-smart-search');
-
-suite('gr-smart-search tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('Autocompletes accounts', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-    });
-  });
-
-  test('Inserts self as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    element._fetchAccounts('owner', 's')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:self'});
-        })
-        .then(() => element._fetchAccounts('owner', 'selfs'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:self'});
-        });
-  });
-
-  test('Inserts me as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'm')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:me'});
-        })
-        .then(() => element._fetchAccounts('owner', 'meme'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:me'});
-        });
-  });
-
-  test('Autocompletes groups', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-    });
-  });
-
-  test('Autocompletes projects', () => {
-    stubRestApi('getSuggestedProjects').callsFake( () =>
-      Promise.resolve({Polygerrit: 0}));
-    return element._fetchProjects('project', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-    });
-  });
-
-  test('Autocomplete doesnt override exact matches to input', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-    });
-  });
-
-  test('Autocompletes accounts with no email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([{name: 'fred'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-    });
-  });
-
-  test('Autocompletes accounts with email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([{email: 'fred@goog.co'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
new file mode 100644
index 0000000..0218a8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @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';
+import './gr-smart-search';
+import {GrSmartSearch} from './gr-smart-search';
+import {stubRestApi} from '../../../test/test-utils';
+import {EmailAddress, GroupId, UrlEncodedRepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-smart-search');
+
+suite('gr-smart-search tests', () => {
+  let element: GrSmartSearch;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Autocompletes accounts', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+    });
+  });
+
+  test('Inserts self as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    element
+      ._fetchAccounts('owner', 's')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:self'});
+      })
+      .then(() => element._fetchAccounts('owner', 'selfs'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:self'});
+      });
+  });
+
+  test('Inserts me as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element
+      ._fetchAccounts('owner', 'm')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:me'});
+      })
+      .then(() => element._fetchAccounts('owner', 'meme'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:me'});
+      });
+  });
+
+  test('Autocompletes groups', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    stubRestApi('getSuggestedProjects').callsFake(() =>
+      Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
+    );
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{name: 'fred'}])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
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 733c940..462c334 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
@@ -64,6 +64,10 @@
   };
 }
 
+export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
 @customElement('gr-diff-builder')
 export class GrDiffBuilderElement extends PolymerElement {
   static get template() {
@@ -212,7 +216,7 @@
     this.$.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, prefs.font_size);
+    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
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 da1d971..51360b9 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
@@ -74,8 +74,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -84,7 +83,7 @@
 
     // Add left-side line number.
     col = this._createElement('col', 'left');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add left-side content.
@@ -92,7 +91,7 @@
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side content.
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 4ecfcbf..a16aa07 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
@@ -78,8 +78,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -88,12 +87,12 @@
 
     // Add left-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add the content.
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 5634238..864fd79 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
@@ -31,7 +31,11 @@
   GrContextControlsShowConfig,
 } from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 
@@ -71,6 +75,20 @@
   }
 }
 
+export function getResponsiveMode(
+  prefs: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -494,9 +512,9 @@
 
     const {beforeNumber, afterNumber} = line;
     if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const lineLimit = !this._prefs.line_wrapping
-        ? this._prefs.line_length
-        : Infinity;
+      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
+      const lineLimit =
+        responsiveMode === 'NONE' ? this._prefs.line_length : Infinity;
       const contentText = this._formatText(
         line.text,
         this._prefs.tab_size,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index c8b3901..f418cfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -23,7 +23,7 @@
   lineNumberToNumber,
 } from '../gr-diff/gr-diff-utils';
 
-const tokenMatcher = new RegExp(/[a-zA-Z0-9_-]+/g);
+const tokenMatcher = new RegExp(/[\w]+/g);
 
 /** CSS class for all tokens. */
 const CSS_TOKEN = 'token';
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 b576896..0f8752d 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
@@ -43,6 +43,9 @@
   @property({type: Boolean})
   saveOnChange = false;
 
+  @property({type: Boolean})
+  showTooltipBelow = false;
+
   private readonly restApiService = appContext.restApiService;
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index 9943b58..3ebb58f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -34,6 +34,7 @@
     id="sideBySideBtn"
     link=""
     has-tooltip=""
+    position-below="[[showTooltipBelow]]"
     class$="[[_computeSideBySideSelected(mode)]]"
     title="Side-by-side diff"
     aria-pressed="[[isSideBySideSelected(mode)]]"
@@ -45,6 +46,7 @@
     id="unifiedBtn"
     link=""
     has-tooltip=""
+    position-below="[[showTooltipBelow]]"
     title="Unified diff"
     class$="[[_computeUnifiedSelected(mode)]]"
     aria-pressed="[[isUnifiedSelected(mode)]]"
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 8d69007d..743f905 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
@@ -343,6 +343,7 @@
             id="modeSelect"
             save-on-change="[[!_diffPrefsDisabled]]"
             mode="{{changeViewState.diffMode}}"
+            show-tooltip-below=""
           ></gr-diff-mode-selector>
         </div>
         <span
@@ -355,6 +356,7 @@
               link=""
               class="prefsButton"
               has-tooltip=""
+              position-below=""
               title="Diff preferences"
               on-click="_handlePrefsTap"
               ><iron-icon icon="gr-icons:settings"></iron-icon
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 3d9bba7..fdde355 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -47,7 +47,10 @@
   DiffPreferencesInfoKey,
 } from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {
+  GrDiffBuilderElement,
+  getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   CoverageRange,
   DiffLayer,
@@ -74,7 +77,10 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
+import {
+  DiffContextExpandedEventDetail,
+  getResponsiveMode,
+} from '../gr-diff-builder/gr-diff-builder';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -726,33 +732,46 @@
     if (!prefs) return;
 
     this.blame = null;
+    this._updatePreferenceStyles(prefs, this.renderPrefs);
 
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this._debounceRenderDiffTable();
+    }
+  }
+
+  _updatePreferenceStyles(
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ) {
     const lineLength =
       this.path === COMMIT_MSG_PATH
         ? COMMIT_MSG_LINE_LENGTH
         : prefs.line_length;
+    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
     const stylesToUpdate: {[key: string]: string} = {};
 
-    if (prefs.line_wrapping) {
-      this._diffTableClass = 'full-width';
-      if (this.viewMode === 'SIDE_BY_SIDE') {
-        stylesToUpdate['--content-width'] = 'none';
-        stylesToUpdate['--line-limit'] = `${lineLength}ch`;
-      }
+    const responsiveMode = getResponsiveMode(prefs, renderPrefs);
+    const responsive =
+      responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY';
+    this._diffTableClass = responsive ? 'responsive' : '';
+    const lineLimit = `${lineLength}ch`;
+    stylesToUpdate['--line-limit'] = lineLimit;
+    stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
+    if (responsiveMode === 'SHRINK_ONLY') {
+      // Calculating ideal (initial) width for the whole table.
+      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
+      stylesToUpdate[
+        '--diff-max-width'
+      ] = `calc(${contentWidth} + ${lineNumberWidth})`;
     } else {
-      this._diffTableClass = '';
-      stylesToUpdate['--content-width'] = `${lineLength}ch`;
+      stylesToUpdate['--diff-max-width'] = 'none';
     }
-
     if (prefs.font_size) {
       stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
     }
 
     this.updateStyles(stylesToUpdate);
-
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this._debounceRenderDiffTable();
-    }
   }
 
   _renderPrefsChanged(renderPrefs?: RenderPreferences) {
@@ -766,6 +785,9 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    if (this.prefs) {
+      this._updatePreferenceStyles(this.prefs, renderPrefs);
+    }
     this.$.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
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 d01943c..5b248da 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
@@ -50,6 +50,9 @@
       background-color: var(--diff-blank-background-color);
     }
     .diffContainer {
+      max-width: var(--diff-max-width, none);
+    }
+    .diffContainer {
       display: flex;
       font-family: var(--monospace-font-family);
       @apply --diff-container-styles;
@@ -169,10 +172,10 @@
     .image-diff .content {
       background-color: var(--diff-blank-background-color);
     }
-    .full-width {
+    .responsive {
       width: 100%;
     }
-    .full-width .contentText {
+    .responsive .contentText {
       white-space: break-spaces;
       word-wrap: break-word;
     }
@@ -423,12 +426,12 @@
       color: var(--link-color);
       text-decoration: none;
     }
-    .full-width td.blame {
+    .responsive td.blame {
       overflow: hidden;
       width: 200px;
     }
     /** Support the line length indicator **/
-    .full-width td.content .contentText {
+    .responsive td.content .contentText {
       /*
       Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
       */
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 53a2915..73b587b 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
@@ -36,7 +36,7 @@
 suite('gr-diff tests', () => {
   let element;
 
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
 
   setup(() => {
 
@@ -85,7 +85,66 @@
     element = basicFixture.instantiate();
     element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
     flush();
-    assert.isNotOk(getComputedStyleValue('--line-limit', element));
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
+  });
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs, line_length: 100};
+      flush();
+      assert.equal(getComputedStyleValue('--line-limit', element), '100ch');
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs, line_length: 100};
+      flush();
+      assert.equal(getComputedStyleValue('--line-limit', element), '100ch');
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', () => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 48px)');
+    });
+
+    test('max-width considers one content column in unified', () => {
+      element.viewMode = 'UNIFIED_DIFF';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(1 * 80ch + 2 * 48px)');
+    });
+
+    test('max-width considers font-size', () => {
+      element.prefs = {...element.prefs, font_size: 13};
+      flush();
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 52px)');
+    });
   });
 
   suite('not logged in', () => {
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 cc66734..1a340b9 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
@@ -15,30 +15,25 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, state} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLabel extends GrLitElement {
   @property({type: Object})
-  account!: AccountInfo;
+  account?: AccountInfo;
 
   @property({type: Object})
   _selfAccount?: AccountInfo;
@@ -49,7 +44,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
   voteableText?: string;
@@ -83,44 +78,190 @@
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    computed:
-      '_computeCancelLeftPadding(hideAvatar, ' +
-      'highlightAttention, account, change, forceAttention)',
+    reflect: true,
   })
   cancelLeftPadding = false;
 
   @property({type: Boolean})
   hideStatus = false;
 
-  @property({type: Object})
+  @state()
   _config?: ServerInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    observer: 'selectedChanged',
+    reflect: true,
   })
   selected = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   deselected = false;
 
   reporting: ReportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          border-radius: var(--label-border-radius);
+          box-sizing: border-box;
+          white-space: nowrap;
+          padding: 0 var(--account-label-padding-horizontal, 0);
+        }
+        /* If the first element is the avatar, then we cancel the left padding,
+        so we can fit nicely into the gr-account-chip rounding. The obvious
+        alternative of 'chip has padding' and 'avatar gets negative margin'
+        does not work, because we need 'overflow:hidden' on the label. */
+        :host([cancelLeftPadding]) {
+          padding-left: 0;
+        }
+        :host::after {
+          content: var(--account-label-suffix);
+        }
+        :host([deselected][selectionChipStyle]) {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--comment-separator-color);
+          border-radius: 8px;
+          color: var(--deemphasized-text-color);
+        }
+        :host([selected][selectionChipStyle]) {
+          background-color: var(--chip-selected-background-color);
+          border: 1px solid var(--chip-selected-background-color);
+          border-radius: 8px;
+          color: var(--chip-selected-text-color);
+        }
+        :host([selected]) iron-icon.attention {
+          color: var(--chip-selected-text-color);
+        }
+        gr-avatar {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          vertical-align: top;
+          position: relative;
+          top: 1px;
+        }
+        #attentionButton {
+          /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+          margin: 0 -4px 0 -4px;
+          vertical-align: top;
+        }
+        iron-icon.attention {
+          color: var(--deemphasized-text-color);
+          width: 12px;
+          height: 12px;
+          vertical-align: top;
+        }
+        iron-icon.status {
+          color: var(--deemphasized-text-color);
+          width: 14px;
+          height: 14px;
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .name {
+          display: inline-block;
+          text-decoration: inherit;
+          vertical-align: top;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: var(--account-max-length, 180px);
+        }
+        .hasAttention .name {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const {account, change, highlightAttention, forceAttention} = this;
+    if (!account) return;
+    const hasAttention =
+      forceAttention ||
+      this._hasUnforcedAttention(highlightAttention, account, change);
+    this.deselected = !this.selected;
+    this.cancelLeftPadding = !this.hideAvatar && !hasAttention;
+    return html`<span>
+        ${!this.hideHovercard
+          ? html`<gr-hovercard-account
+              for="hovercardTarget"
+              .account="${account}"
+              .change="${change}"
+              ?highlight-attention=${highlightAttention}
+              .voteableText=${this.voteableText}
+            ></gr-hovercard-account>`
+          : ''}
+        ${hasAttention
+          ? html`<gr-button
+              id="attentionButton"
+              link=""
+              aria-label="Remove user from attention set"
+              @click=${this._handleRemoveAttentionClick}
+              ?disabled=${!this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                this.selected,
+                this._selfAccount
+              )}
+              ?has-tooltip=${this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                false,
+                this._selfAccount
+              )}
+              title="${this._computeAttentionIconTitle(
+                highlightAttention,
+                account,
+                change,
+                forceAttention,
+                this.selected,
+                this._selfAccount
+              )}"
+              ><iron-icon
+                class="attention"
+                icon="gr-icons:attention"
+              ></iron-icon>
+            </gr-button>`
+          : ''}
+      </span>
+      <span
+        id="hovercardTarget"
+        class="${classMap({
+          hasAttention: !!hasAttention,
+        })}"
+      >
+        ${!this.hideAvatar
+          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
+          : ''}
+        <span class="text" part="gr-account-label-text">
+          <span class="name"
+            >${this._computeName(account, this.firstName, this._config)}</span
+          >
+          ${!this.hideStatus && account.status
+            ? html`<iron-icon
+                class="status"
+                icon="gr-icons:calendar"
+              ></iron-icon>`
+            : ''}
+        </span>
+      </span>`;
+  }
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -129,72 +270,42 @@
     });
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
-      this.change = {...this.change};
+      if (this.change) this.change = {...this.change};
     });
   }
 
-  selectedChanged(selected?: boolean) {
-    this.deselected = !selected;
-  }
-
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _computeCancelLeftPadding(
-    hideAvatar: boolean,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return (
-      !hideAvatar && !this._hasAttention(highlight, account, change, force)
-    );
-  }
-
-  _hasAttention(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return force || this._hasUnforcedAttention(highlight, account, change);
-  }
-
   _hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return (
       this._isAttentionSetEnabled(highlight, account, change) &&
+      change &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
     );
   }
 
-  _computeHasAttentionClass(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
+  _computeName(
+    account?: AccountInfo,
+    firstName?: boolean,
+    config?: ServerInfo
   ) {
-    return this._hasAttention(highlight, account, change, force)
-      ? 'hasAttention'
-      : '';
-  }
-
-  _computeName(account: AccountInfo, firstName: boolean, config?: ServerInfo) {
     return getDisplayName(config, account, firstName);
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
@@ -236,6 +347,7 @@
   }
 
   _reportingDetails() {
+    if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
@@ -259,7 +371,7 @@
   _computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
+    change: ChangeInfo | undefined,
     selected: boolean,
     selfAccount?: AccountInfo
   ) {
@@ -273,7 +385,7 @@
   _computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
+    change: ChangeInfo | undefined,
     force: boolean,
     selected: boolean,
     selfAccount?: AccountInfo
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
deleted file mode 100644
index 352763b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-      position: relative;
-      border-radius: var(--label-border-radius);
-      box-sizing: border-box;
-      white-space: nowrap;
-      padding: 0 var(--account-label-padding-horizontal, 0);
-    }
-    /* If the first element is the avatar, then we cancel the left padding, so
-       we can fit nicely into the gr-account-chip rounding.
-       The obvious alternative of 'chip has padding' and 'avatar gets negative
-       margin' does not work, because we need 'overflow:hidden' on the label. */
-    :host([cancel-left-padding]) {
-      padding-left: 0;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host([deselected][selection-chip-style]) {
-      background-color: var(--background-color-primary);
-      border: 1px solid var(--comment-separator-color);
-      border-radius: 8px;
-      color: var(--deemphasized-text-color);
-    }
-    :host([selected][selection-chip-style]) {
-      background-color: var(--chip-selected-background-color);
-      border: 1px solid var(--chip-selected-background-color);
-      border-radius: 8px;
-      color: var(--chip-selected-text-color);
-    }
-    :host([selected]) iron-icon.attention {
-      color: var(--chip-selected-text-color);
-    }
-    gr-avatar {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-      vertical-align: top;
-      position: relative;
-      top: 1px;
-    }
-    #attentionButton {
-      /* This negates the 4px horizontal padding, which we appreciate as a
-         larger click target, but which we don't want to consume space. :-) */
-      margin: 0 -4px 0 -4px;
-      vertical-align: top;
-    }
-    iron-icon.attention {
-      color: var(--deemphasized-text-color);
-      width: 12px;
-      height: 12px;
-      vertical-align: top;
-    }
-    iron-icon.status {
-      color: var(--deemphasized-text-color);
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-    .name {
-      display: inline-block;
-      text-decoration: inherit;
-      vertical-align: top;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      max-width: var(--account-max-length, 180px);
-    }
-    .hasAttention .name {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <span>
-    <template is="dom-if" if="[[!hideHovercard]]">
-      <gr-hovercard-account
-        for="hovercardTarget"
-        account="[[account]]"
-        change="[[change]]"
-        highlight-attention="[[highlightAttention]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-hovercard-account>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
-    >
-      <gr-button
-        id="attentionButton"
-        link=""
-        aria-label="Remove user from attention set"
-        on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(highlightAttention, account, change, selected, _selfAccount)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(highlightAttention, account, change, false, _selfAccount)]]"
-        title="[[_computeAttentionIconTitle(highlightAttention, account, change, forceAttention, selected, _selfAccount)]]"
-        ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
-      </gr-button>
-    </template>
-  </span>
-  <span
-    id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
-  >
-    <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
-    </template>
-    <span class="text" part="gr-account-label-text">
-      <span class="name">[[_computeName(account, firstName, _config)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-`;
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 317806c..a610ffa 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
@@ -89,12 +89,12 @@
         <gr-account-label
           .account="${this.account}"
           .change="${this.change}"
-          ?force-attention=${this.forceAttention}
-          ?highlight-attention=${this.highlightAttention}
-          ?hide-avatar=${this.hideAvatar}
-          ?hide-status=${this.hideStatus}
-          ?first-name=${this.firstName}
-          .voteable-text=${this.voteableText}
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          ?hideAvatar=${this.hideAvatar}
+          ?hideStatus=${this.hideStatus}
+          ?firstName=${this.firstName}
+          .voteableText=${this.voteableText}
           part="gr-account-link-text => gr-account-label-text"
         >
         </gr-account-label>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
similarity index 60%
rename from polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
rename to polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index df8632f..bb70855 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -15,19 +15,26 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-avatar.js';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
+import '../../../test/common-test-setup-karma';
+import './gr-avatar';
+import {GrAvatar} from './gr-avatar';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {AvatarInfo} from '../../../types/common';
+import {
+  createAccountWithEmail,
+  createAccountWithId,
+} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-avatar');
 
 suite('gr-avatar tests', () => {
-  let element;
-  const defaultAvatars = [
+  let element: GrAvatar;
+  const defaultAvatars: AvatarInfo[] = [
     {
       url: 'https://cdn.example.com/s12-p/photo.jpg',
       height: 12,
+      width: 0,
     },
   ];
 
@@ -36,68 +43,74 @@
   });
 
   test('account without avatar', () => {
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-        }),
-        '');
+    assert.equal(element._buildAvatarURL(createAccountWithId(123)), '');
   });
 
   test('methods', () => {
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: defaultAvatars,
-        }),
-        '/accounts/123/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: defaultAvatars,
+      }),
+      '/accounts/123/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          email: 'test@example.com',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/test%40example.com/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithEmail('test@example.com'),
+        avatars: defaultAvatars,
+      }),
+      '/accounts/test%40example.com/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          name: 'John Doe',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/John%20Doe/avatar?s=16');
+      element._buildAvatarURL({
+        name: 'John Doe',
+        avatars: defaultAvatars,
+      }),
+      '/accounts/John%20Doe/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          username: 'John_Doe',
-          avatars: defaultAvatars,
-        }),
-        '/accounts/John_Doe/avatar?s=16');
+      element._buildAvatarURL({
+        username: 'John_Doe',
+        avatars: defaultAvatars,
+      }),
+      '/accounts/John_Doe/avatar?s=16'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s12-p/photo.jpg',
-              height: 12,
-            },
-            {
-              url: 'https://cdn.example.com/s16-p/photo.jpg',
-              height: 16,
-            },
-            {
-              url: 'https://cdn.example.com/s100-p/photo.jpg',
-              height: 100,
-            },
-          ],
-        }),
-        'https://cdn.example.com/s16-p/photo.jpg');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s12-p/photo.jpg',
+            height: 12,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s16-p/photo.jpg',
+            height: 16,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s100-p/photo.jpg',
+            height: 100,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      'https://cdn.example.com/s16-p/photo.jpg'
+    );
     assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s95-p/photo.jpg',
-              height: 95,
-            },
-          ],
-        }),
-        '/accounts/123/avatar?s=16');
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s95-p/photo.jpg',
+            height: 95,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      '/accounts/123/avatar?s=16'
+    );
     assert.equal(element._buildAvatarURL(undefined), '');
   });
 
@@ -114,7 +127,7 @@
 
       element.imageSize = 64;
       element.account = {
-        _account_id: 123,
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
       };
       flush();
@@ -131,14 +144,14 @@
         assert.isFalse(element.hasAttribute('hidden'));
 
         assert.isTrue(
-            element.style.backgroundImage.includes(
-                '/accounts/123/avatar?s=64'));
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64')
+        );
       });
     });
   });
 
   suite('plugin has avatars', () => {
-    let element;
+    let element: GrAvatar;
 
     setup(() => {
       stub('gr-avatar', '_getConfig').callsFake(() =>
@@ -166,7 +179,7 @@
   });
 
   suite('config not set', () => {
-    let element;
+    let element: GrAvatar;
 
     setup(() => {
       stub('gr-avatar', '_getConfig').callsFake(() => Promise.resolve({}));
@@ -180,7 +193,7 @@
 
       element.imageSize = 64;
       element.account = {
-        _account_id: 123,
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
       };
       // Emulate plugins loaded.
@@ -195,4 +208,3 @@
     });
   });
 });
-
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 65e8e9f..0bd02d5 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
@@ -77,11 +77,11 @@
   @property({type: Object})
   resolveWeblinks?: GeneratedWebLink[] = [];
 
-  _computeStatusString(status: ChangeStates) {
+  _computeStatusString(status?: ChangeStates) {
     if (status === ChangeStates.WIP && !this.flat) {
       return 'Work in Progress';
     }
-    return status;
+    return status ?? '';
   }
 
   _toClassName(str?: ChangeStates) {
@@ -107,14 +107,14 @@
     revertedChange?: ChangeInfo,
     resolveWeblinks?: GeneratedWebLink[],
     status?: ChangeStates
-  ): string | undefined {
+  ): string {
     if (revertedChange) {
       return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
     }
     if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
-      return resolveWeblinks[0].url;
+      return resolveWeblinks[0].url ?? '';
     }
-    return undefined;
+    return '';
   }
 
   showResolveIcon(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index a848b2f..3e0b9a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -262,7 +262,7 @@
           <gr-account-label
             account="[[_getAuthor(comment, _selfAccount)]]"
             class$="[[_computeAccountLabelClass(draft)]]"
-            hide-status=""
+            hideStatus
           >
           </gr-account-label>
         </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index bbf3442..a218959 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -367,23 +367,20 @@
         getTimerStub = stubReporting('getTimer').returns(mockTimer);
       });
 
-      test('create', () => {
+      test('create', async () => {
         element.patchNum = 1 as PatchSetNum;
         element.comment = {};
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.equal(
-            (queryAndAssert(
-              element,
-              'gr-account-label'
-            ).shadowRoot?.querySelector(
-              'span.name'
-            ) as HTMLSpanElement).innerText.trim(),
-            'Dhruv Srivastava'
-          );
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
+        await element._handleSave(mockEvent);
+        await flush();
+        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
+        const spanName = queryAndAssert<HTMLSpanElement>(
+          grAccountLabel,
+          'span.name'
+        );
+        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
+        assert.isTrue(endStub.calledOnce);
+        assert.isTrue(getTimerStub.calledOnce);
+        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
       });
 
       test('update', () => {
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 4a2bcee..36cffb7 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
@@ -20,6 +20,7 @@
 import {IronIconElement} from '@polymer/iron-icon';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {classMap} from 'lit-html/directives/class-map';
+import {ifDefined} from 'lit-html/directives/if-defined';
 import {css, customElement, html, property} from 'lit-element';
 import {GrLitElement} from '../../lit/gr-lit-element';
 import {GrButton} from '../gr-button/gr-button';
@@ -126,7 +127,7 @@
           link=""
           ?has-tooltip=${this.hasTooltip}
           class="copyToClipboard"
-          title="${this.buttonTitle}"
+          title="${ifDefined(this.buttonTitle)}"
           @click="${this._copyToClipboard}"
           aria-label="Click to copy to clipboard"
         >
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
rename to polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 45847d7..ef62fe9 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -15,14 +15,16 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-copy-clipboard.js';
-import {queryAndAssert} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-copy-clipboard';
+import {GrCopyClipboard} from './gr-copy-clipboard';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-copy-clipboard');
 
 suite('gr-copy-clipboard tests', () => {
-  let element;
+  let element: GrCopyClipboard;
 
   setup(async () => {
     element = basicFixture.instantiate();
@@ -33,35 +35,34 @@
 
   test('copy to clipboard', () => {
     const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
-    const copyBtn = element.shadowRoot
-        .querySelector('.copyToClipboard');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
     MockInteractions.click(copyBtn);
     assert.isTrue(clipboardSpy.called);
   });
 
   test('focusOnCopy', () => {
     element.focusOnCopy();
-    const activeElement = element.shadowRoot.activeElement;
-    const button = element.shadowRoot.querySelector('.copyToClipboard');
+    const activeElement = element.shadowRoot!.activeElement;
+    const button = queryAndAssert(element, '.copyToClipboard');
     assert.deepEqual(activeElement, button);
   });
 
   test('_handleInputClick', () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    const inputElement = element.shadowRoot.querySelector('input');
+    const inputElement = queryAndAssert(element, 'input') as HTMLInputElement;
     MockInteractions.tap(inputElement);
     assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+    assert.equal(inputElement.selectionEnd, element.text!.length! - 1);
   });
 
   test('hideInput', async () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
     const input = queryAndAssert(element, 'input');
@@ -76,10 +77,8 @@
     divParent.appendChild(element);
     const clickStub = sinon.stub();
     divParent.addEventListener('click', clickStub);
-    element.stopPropagation = true;
-    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
     MockInteractions.tap(copyBtn);
     assert.isFalse(clickStub.called);
   });
 });
-
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 017ba50..9dce127 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
@@ -87,7 +87,7 @@
   }
 
   /**
-   * Move the cursor forward. Clipped to the ends of the stop list.
+   * Move the cursor forward. Clipped to the end of the stop list.
    *
    * @param options.filter Skips any stops for which filter returns false.
    * @param options.getTargetHeight Optional function to calculate the
@@ -95,22 +95,36 @@
    *    sometimes different, used by the diff cursor.
    * @param options.clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param options.circular When on last element, you get to first element.
    * @return If a move was performed or why not.
-   * @private
    */
   next(
     options: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(1, options);
   }
 
+  /**
+   * Move the cursor backward. Clipped to the beginning of stop list.
+   *
+   * @param options.filter Skips any stops for which filter returns false.
+   * @param options.getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param options.clipToTop When none of the next indices match, move
+   * back to first instead of to last.
+   * @param options.circular When on first element, you get to last element.
+   * @return  If a move was performed or why not.
+   */
   previous(
     options: {
       filter?: (stop: HTMLElement) => boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(-1, options);
@@ -276,34 +290,18 @@
     }
   }
 
-  /**
-   * Move the cursor forward or backward by delta. Clipped to the beginning or
-   * end of stop list.
-   *
-   * @param delta either -1 or 1.
-   * @param options.abort Will abort moving the cursor when encountering a
-   *    stop for which this condition is met. Will abort even if the stop
-   *    would have been filtered
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
-   * @param options.getTargetHeight Optional function to calculate the
-   * height of the target's 'section'. The height of the target itself is
-   * sometimes different, used by the diff cursor.
-   * @param options.clipToTop When none of the next indices match, move
-   * back to first instead of to last.
-   * @return  If a move was performed or why not.
-   * @private
-   */
   _moveCursor(
     delta: number,
     {
       filter,
       getTargetHeight,
       clipToTop,
+      circular,
     }: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     if (!this.stops.length) {
@@ -326,7 +324,10 @@
         (delta > 0 && newIndex >= this.stops.length) ||
         (delta < 0 && newIndex < 0)
       ) {
-        newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newIndex =
+          (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop
+            ? 0
+            : this.stops.length - 1;
         newStop = this.stops[newIndex];
         clipped = true;
         break;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index ba7e4f8..d0bd420 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -255,6 +255,25 @@
     assert.isTrue(cursor.target.focus.called);
   });
 
+  suite('circular options', () => {
+    const options = {circular: true};
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+    });
+
+    test('previous() on first element goes to last element', () => {
+      cursor.setCursor(list.children[0]);
+      cursor.previous(options);
+      assert.equal(cursor.index, list.children.length - 1);
+    });
+
+    test('next() on last element goes to first element', () => {
+      cursor.setCursor(list.children[list.children.length - 1]);
+      cursor.next(options);
+      assert.equal(cursor.index, 0);
+    });
+  });
+
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
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 83cd380..592efba 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
@@ -106,13 +106,14 @@
   _saveDisabled!: boolean;
 
   @property({type: String, observer: '_newContentChanged'})
-  _newContent?: string;
+  _newContent = '';
 
   private readonly storage = appContext.storageService;
 
   private readonly reporting = appContext.reportingService;
 
-  private storeTask?: DelayedTask;
+  // Tests use this so needs to be non private
+  storeTask?: DelayedTask;
 
   /** @override */
   ready() {
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 c6ff903..7877a1f 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
@@ -69,7 +69,7 @@
       box-shadow: var(--elevation-level-1);
       /* slightly up to cover rounded corner of the commit msg */
       margin-top: calc(-1 * var(--spacing-xs));
-      /* To make this bar pop over editor, since editor has relative position. 
+      /* To make this bar pop over editor, since editor has relative position.
       */
       position: relative;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
rename to polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 94a7b96..074678e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -15,13 +15,17 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-editable-content.js';
+import '../../../test/common-test-setup-karma';
+import './gr-editable-content';
+import {GrEditableContent} from './gr-editable-content';
+import {queryAndAssert, stubStorage} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../gr-button/gr-button';
 
 const basicFixture = fixtureFromElement('gr-editable-content');
 
 suite('gr-editable-content tests', () => {
-  let element;
+  let element: GrEditableContent;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -33,8 +37,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
@@ -44,8 +47,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-cancel', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.cancel-button'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
 
     assert.isTrue(handler.called);
   });
@@ -79,19 +81,22 @@
     });
 
     test('save button is disabled initially', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
 
     test('save button is enabled when content changes', () => {
       element._newContent = 'new content';
-      assert.isFalse(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
   });
 
   suite('storageKey and related behavior', () => {
-    let dispatchSpy;
+    let dispatchSpy: sinon.SinonSpy;
+
     setup(() => {
       element.content = 'current content';
       element.storageKey = 'test';
@@ -99,8 +104,10 @@
     });
 
     test('editing toggled to true, has stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'stored content'});
+      stubStorage('getEditableContentItem').returns({
+        message: 'stored content',
+        updated: 0,
+      });
       element.editing = true;
 
       assert.equal(element._newContent, 'stored content');
@@ -109,8 +116,7 @@
     });
 
     test('editing toggled to true, has no stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({});
+      stubStorage('getEditableContentItem').returns(null);
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
@@ -118,28 +124,26 @@
     });
 
     test('edits are cached', () => {
-      const storeStub =
-          sinon.stub(element.storage, 'setEditableContentItem');
-      const eraseStub =
-          sinon.stub(element.storage, 'eraseEditableContentItem');
+      const storeStub = stubStorage('setEditableContentItem');
+      const eraseStub = stubStorage('eraseEditableContentItem');
       element.editing = true;
 
       element._newContent = 'new content';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-          [element.storageKey, element._newContent],
-          storeStub.lastCall.args);
+        [element.storageKey, element._newContent],
+        storeStub.lastCall.args
+      );
 
       element._newContent = '';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
       assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
     });
   });
 });
-
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 0193197..ddca5c5 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
@@ -183,7 +183,9 @@
         // include pre or all regular lines but stop at next new line
         while (
           this._isPreFormat(lines[nextI]) ||
-          (this._isRegularLine(lines[nextI]) && lines[nextI].length)
+          (this._isRegularLine(lines[nextI]) &&
+            !this._isWhitespaceLine(lines[nextI]) &&
+            lines[nextI].length)
         ) {
           nextI++;
         }
@@ -255,13 +257,17 @@
   }
 
   _isPreFormat(line: string) {
-    return line && /^[ \t]/.test(line);
+    return line && /^[ \t]/.test(line) && !this._isWhitespaceLine(line);
   }
 
   _isList(line: string) {
     return line && /^[-*] /.test(line);
   }
 
+  _isWhitespaceLine(line: string) {
+    return line && /^\s+$/.test(line);
+  }
+
   _makeLinkedText(content = '', isPre?: boolean) {
     const text = document.createElement('gr-linked-text');
     text.config = this.config;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
index 3e05f11..8464af7 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
@@ -297,6 +297,14 @@
     assertBlock(result, 1, 'paragraph', 'B');
   });
 
+  test('pre format 5', () => {
+    const comment = '  Q\n    <R>\n  S\n \nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 1, 'paragraph', ' \nB');
+  });
+
   test('quote 1', () => {
     const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
     const result = element._computeBlocks(comment);
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 2812b47..287ed1b 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
@@ -14,12 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-linked-text_html';
 import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, query} from 'lit-element';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,17 +25,10 @@
   }
 }
 
-export interface GrLinkedText {
-  $: {
-    output: HTMLSpanElement;
-  };
-}
-
 @customElement('gr-linked-text')
-export class GrLinkedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLinkedText extends GrLitElement {
+  @query('#output')
+  outputElement?: HTMLSpanElement;
 
   @property({type: Boolean})
   removeZeroWidthSpace?: boolean;
@@ -46,61 +37,63 @@
   @property({type: String})
   content: string | null = null;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   pre = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
   @property({type: Object})
   config?: LinkTextParserConfig;
 
-  @observe('content')
-  _contentChanged(content: string | null) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        :host([pre]) span {
+          white-space: var(--linked-text-white-space, pre-wrap);
+          word-wrap: var(--linked-text-word-wrap, break-word);
+        }
+        :host([disabled]) a {
+          color: inherit;
+          text-decoration: none;
+          pointer-events: none;
+        }
+        a {
+          color: var(--link-color);
+        }
+      `,
+    ];
+  }
+
+  render() {
     if (!this.config) {
       return;
     }
-    this.$.output.textContent = content;
+    return html`<span id="output">${this.content}</span>`;
   }
 
-  /**
-   * Because either the source text or the linkification config has changed,
-   * the content should be re-parsed.
-   *
-   * @param content The raw, un-linkified source string to parse.
-   * @param config The server config specifying commentLink patterns
-   */
-  @observe('content', 'config')
-  _contentOrConfigChanged(
-    content: string | null,
-    config?: LinkTextParserConfig
-  ) {
-    if (!config) {
-      return;
-    }
-
+  updated() {
+    if (!this.outputElement || !this.config) return;
+    this.outputElement.textContent = '';
     // TODO(TS): mapCommentlinks always has value, remove
     if (!GerritNav.mapCommentlinks) return;
-    config = GerritNav.mapCommentlinks(config);
-    const output = this.$.output;
-    output.textContent = '';
+    const config = GerritNav.mapCommentlinks(this.config);
     const parser = new GrLinkTextParser(
       config,
       (text: string | null, href: string | null, fragment?: DocumentFragment) =>
         this._handleParseResult(text, href, fragment),
       this.removeZeroWidthSpace
     );
-    parser.parse(content);
-
+    parser.parse(this.content);
     // Ensure that external links originating from HTML commentlink configs
     // open in a new tab. @see Issue 5567
     // Ensure links to the same host originating from commentlink configs
     // open in the same tab. When target is not set - default is _self
     // @see Issue 4616
-    output.querySelectorAll('a').forEach(anchor => {
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
       if (anchor.hostname === window.location.hostname) {
         anchor.removeAttribute('target');
       } else {
@@ -124,7 +117,8 @@
     href: string | null,
     fragment?: DocumentFragment
   ) {
-    const output = this.$.output;
+    const output = this.outputElement;
+    if (!output) return;
     if (href) {
       const a = document.createElement('a');
       a.setAttribute('href', href);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
deleted file mode 100644
index 0d44bc8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-    }
-    :host([pre]) span {
-      white-space: var(--linked-text-white-space, pre-wrap);
-      word-wrap: var(--linked-text-word-wrap, break-word);
-    }
-    :host([disabled]) a {
-      color: inherit;
-      text-decoration: none;
-      pointer-events: none;
-    }
-    a {
-      color: var(--link-color);
-    }
-  </style>
-  <span id="output"></span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index b2cdba1..c97c168 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -85,10 +85,11 @@
     window.CANONICAL_PATH = originalCanonicalPath;
   });
 
-  test('URL pattern was parsed and linked.', () => {
+  test('URL pattern was parsed and linked.', async () => {
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     element.content = url;
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
@@ -97,9 +98,10 @@
     assert.equal(linkEl.textContent, url);
   });
 
-  test('Bug pattern was parsed and linked', () => {
+  test('Bug pattern was parsed and linked', async () => {
     // "Issue/Bug" pattern.
     element.content = 'Issue 3650';
+    await flush();
 
     let linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
@@ -109,6 +111,7 @@
     assert.equal(linkEl.textContent, 'Issue 3650');
 
     element.content = 'Bug 3650';
+    await flush();
     linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
@@ -117,10 +120,10 @@
     assert.equal(linkEl.textContent, 'Bug 3650');
   });
 
-  test('Pattern with same prefix as link was correctly parsed', () => {
+  test('Pattern with same prefix as link was correctly parsed', async () => {
     // Pattern starts with the same prefix (`http`) as the url.
     element.content = 'httpexample 3650';
-
+    await flush();
     assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
@@ -130,12 +133,12 @@
     assert.equal(linkEl.textContent, 'httpexample 3650');
   });
 
-  test('Change-Id pattern was parsed and linked', () => {
+  test('Change-Id pattern was parsed and linked', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -147,14 +150,14 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Change-Id pattern was parsed and linked with base url', () => {
+  test('Change-Id pattern was parsed and linked with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -166,8 +169,9 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Multiple matches', () => {
+  test('Multiple matches', async () => {
     element.content = 'Issue 3650\nIssue 3450';
+    await flush();
     const linkEl1 = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     const linkEl2 = queryAndAssert(element, '#output')
@@ -188,7 +192,7 @@
     assert.equal(linkEl2.textContent, 'Issue 3450');
   });
 
-  test('Change-Id pattern parsed before bug pattern', () => {
+  test('Change-Id pattern parsed before bug pattern', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
@@ -200,7 +204,7 @@
     const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
 
     element.content = prefix + changeID + bug;
-
+    await flush();
     const textNode = queryAndAssert(element, '#output').childNodes[0];
     const changeLinkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
@@ -218,8 +222,9 @@
     assert.equal(bugLinkEl.textContent, 'Issue 3650');
   });
 
-  test('html field in link config', () => {
+  test('html field in link config', async () => {
     element.content = 'google:do a barrel roll';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(
@@ -229,52 +234,58 @@
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
-  test('removing hash from links', () => {
+  test('removing hash from links', async () => {
     element.content = 'hash:foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('html with base url', () => {
+  test('html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('a is not at start', () => {
+  test('a is not at start', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('hash html with base url', () => {
+  test('hash html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
+    await flush();
     const linkEl = queryAndAssert(element, '#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('disabled config', () => {
+  test('disabled config', async () => {
     element.content = 'foo:baz';
+    await flush();
     assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
   });
 
-  test('R=email labels link correctly', () => {
+  test('R=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').textContent,
       'R=test@google.com'
@@ -285,9 +296,10 @@
     );
   });
 
-  test('CC=email labels link correctly', () => {
+  test('CC=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').textContent,
       'CC=test@google.com'
@@ -298,36 +310,42 @@
     );
   });
 
-  test('only {http,https,mailto} protocols are linkified', () => {
+  test('only {http,https,mailto} protocols are linkified', async () => {
     element.content = 'xx mailto:test@google.com yy';
+    await flush();
     let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('links without leading whitespace are linkified', () => {
+  test('links without leading whitespace are linkified', async () => {
     element.content = 'xx abcmailto:test@google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx abc'
@@ -338,6 +356,7 @@
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx def'
@@ -348,6 +367,7 @@
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx qwe'
@@ -359,6 +379,7 @@
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
+    await flush();
     assert.equal(
       queryAndAssert(element, '#output').innerHTML.substr(0, 6),
       'xx абв'
@@ -369,15 +390,17 @@
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
+    await flush();
     links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('overlapping links', () => {
+  test('overlapping links', async () => {
     element.config = {
       b1: {
         match: '(B:\\s*)(\\d+)',
@@ -389,7 +412,8 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root!.querySelectorAll('a');
+    await flush();
+    const links = element.shadowRoot!.querySelectorAll('a');
 
     assert.equal(links.length, 2);
     assert.equal(
@@ -403,12 +427,4 @@
     assert.equal(links[1].href, 'ftp://foo/45');
     assert.equal(links[1].textContent, '45');
   });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
 });
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 0f2608e..96c05ee 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -43,6 +43,7 @@
   ActionNameToActionInfoMap,
   ApprovalInfo,
   AuthInfo,
+  AvatarInfo,
   BasePatchSetNum,
   BranchName,
   BrandType,
@@ -124,6 +125,7 @@
   ActionNameToActionInfoMap,
   ApprovalInfo,
   AuthInfo,
+  AvatarInfo,
   BasePatchSetNum,
   BranchName,
   BrandType,
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 16338335..223f290 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -27,6 +27,7 @@
   DiffFileMetaInfo as DiffFileMetaInfoApi,
   DiffInfo as DiffInfoApi,
   DiffIntralineInfo,
+  DiffResponsiveMode,
   DiffPreferencesInfo as DiffPreferenceInfoApi,
   IgnoreWhitespaceType,
   MarkLength,
@@ -37,6 +38,7 @@
 export {
   ChangeType,
   DiffIntralineInfo,
+  DiffResponsiveMode,
   IgnoreWhitespaceType,
   MarkLength,
   MoveDetails,
diff --git a/polygerrit-ui/app/utils/async-util_test.js b/polygerrit-ui/app/utils/async-util_test.js
deleted file mode 100644
index df29e97..0000000
--- a/polygerrit-ui/app/utils/async-util_test.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {asyncForeach} from './async-util.js';
-
-suite('async-util tests', () => {
-  test('loops over each item', () => {
-    const fn = sinon.stub().returns(Promise.resolve());
-    return asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(fn.calledThrice);
-          assert.equal(fn.getCall(0).args[0], 1);
-          assert.equal(fn.getCall(1).args[0], 2);
-          assert.equal(fn.getCall(2).args[0], 3);
-        });
-  });
-
-  test('halts on stop condition', () => {
-    const stub = sinon.stub();
-    const fn = (e, stop) => {
-      stub(e);
-      stop();
-      return Promise.resolve();
-    };
-    return asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(stub.calledOnce);
-          assert.equal(stub.lastCall.args[0], 1);
-        });
-  });
-});
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
new file mode 100644
index 0000000..5c8f610
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {asyncForeach} from './async-util';
+
+suite('async-util tests', () => {
+  test('loops over each item', async () => {
+    const fn = sinon.stub().resolves();
+
+    await asyncForeach([1, 2, 3], fn);
+
+    assert.isTrue(fn.calledThrice);
+    assert.equal(fn.firstCall.firstArg, 1);
+    assert.equal(fn.secondCall.firstArg, 2);
+    assert.equal(fn.thirdCall.firstArg, 3);
+  });
+
+  test('halts on stop condition', async () => {
+    const stub = sinon.stub();
+    const fn = (item: number, stopCallback: () => void) => {
+      stub(item);
+      stopCallback();
+      return Promise.resolve();
+    };
+
+    await asyncForeach([1, 2, 3], fn);
+
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.firstArg, 1);
+  });
+});
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
deleted file mode 100644
index 96d5bc1..0000000
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ /dev/null
@@ -1,144 +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 {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate, wasYesterday} from './date-util.js';
-
-suite('date-util tests', () => {
-  suite('parseDate', () => {
-    test('parseDate server date', () => {
-      const parsed = parseDate('2015-09-15 20:34:00.000000000');
-      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
-    });
-  });
-
-  suite('isValidDate', () => {
-    test('date is valid', () => {
-      assert.isTrue(isValidDate(new Date()));
-    });
-    test('broken date is invalid', () => {
-      assert.isFalse(isValidDate(new Date('xxx')));
-    });
-  });
-
-  suite('fromNow', () => {
-    test('test all variants', () => {
-      const fakeNow = new Date('May 08 2020 12:00:00');
-      sinon.useFakeTimers(fakeNow.getTime());
-      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
-      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
-      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
-      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
-      assert.equal(
-          '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
-      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
-      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
-      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
-      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
-      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
-      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
-      assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
-      assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
-    });
-    test('rounding error', () => {
-      const fakeNow = new Date('May 08 2020 12:00:00');
-      sinon.useFakeTimers(fakeNow.getTime());
-      assert.equal('2 hours ago', fromNow(new Date('May 08 2020 9:30:00')));
-    });
-  });
-
-  suite('isWithinDay', () => {
-    test('basics works', () => {
-      assert.isTrue(isWithinDay(new Date('May 08 2020 12:00:00'),
-          new Date('May 08 2020 02:00:00')));
-      assert.isFalse(isWithinDay(new Date('May 08 2020 12:00:00'),
-          new Date('May 07 2020 12:00:00')));
-    });
-  });
-
-  suite('wasYesterday', () => {
-    test('less 24 hours', () => {
-      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
-          new Date('May 08 2020 02:00:00')));
-      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
-          new Date('May 07 2020 12:00:00')));
-    });
-    test('more 24 hours', () => {
-      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
-          new Date('May 07 2020 2:00:00')));
-      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
-          new Date('May 06 2020 14:00:00')));
-    });
-  });
-
-  suite('isWithinHalfYear', () => {
-    test('basics works', () => {
-      assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
-          new Date('Feb 08 2020 12:00:00')));
-      assert.isFalse(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
-          new Date('Nov 07 2019 12:00:00')));
-    });
-  });
-
-  suite('formatDate', () => {
-    test('works for standard format', () => {
-      const stdFormat = 'MMM DD, YYYY';
-      assert.equal('May 08, 2020',
-          formatDate(new Date('May 08 2020 12:00:00'), stdFormat));
-      assert.equal('Feb 28, 2020',
-          formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat));
-
-      const time24Format = 'HH:mm:ss';
-      assert.equal('Feb 28, 2020 12:01:12',
-          formatDate(new Date('Feb 28 2020 12:01:12'), stdFormat + ' '
-          + time24Format));
-    });
-    test('works for euro format', () => {
-      const euroFormat = 'DD.MM.YYYY';
-      assert.equal('01.12.2019',
-          formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat));
-      assert.equal('20.01.2002',
-          formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat));
-
-      const time24Format = 'HH:mm:ss';
-      assert.equal('28.02.2020 00:01:12',
-          formatDate(new Date('Feb 28 2020 00:01:12'), euroFormat + ' '
-          + time24Format));
-    });
-    test('works for iso format', () => {
-      const isoFormat = 'YYYY-MM-DD';
-      assert.equal('2015-01-01',
-          formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat));
-      assert.equal('2013-07-03',
-          formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat));
-
-      const timeFormat = 'h:mm:ss A';
-      assert.equal('2013-07-03 5:00:00 AM',
-          formatDate(new Date('Jul 03 2013 05:00:00'), isoFormat + ' '
-          + timeFormat));
-      assert.equal('2013-07-03 5:00:00 PM',
-          formatDate(new Date('Jul 03 2013 17:00:00'), isoFormat + ' '
-          + timeFormat));
-    });
-    test('h:mm:ss A shows correctly midnight and midday', () => {
-      const timeFormat = 'h:mm A';
-      assert.equal('12:14 PM',
-          formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat));
-      assert.equal('12:15 AM',
-          formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
new file mode 100644
index 0000000..f17ced3
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -0,0 +1,219 @@
+/**
+ * @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 {Timestamp} from '../types/common';
+import '../test/common-test-setup-karma';
+import {
+  isValidDate,
+  parseDate,
+  fromNow,
+  isWithinDay,
+  isWithinHalfYear,
+  formatDate,
+  wasYesterday,
+} from './date-util';
+
+suite('date-util tests', () => {
+  suite('parseDate', () => {
+    test('parseDate server date', () => {
+      const parsed = parseDate('2015-09-15 20:34:00.000000000' as Timestamp);
+      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
+    });
+  });
+
+  suite('isValidDate', () => {
+    test('date is valid', () => {
+      assert.isTrue(isValidDate(new Date()));
+    });
+    test('broken date is invalid', () => {
+      assert.isFalse(isValidDate(new Date('xxx')));
+    });
+  });
+
+  suite('fromNow', () => {
+    test('test all variants', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
+      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
+      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal(
+        '1 hour 5 min ago',
+        fromNow(new Date('May 08 2020 10:55:00'))
+      );
+      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
+      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
+      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
+      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
+      assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
+      assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
+    });
+    test('rounding error', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('2 hours ago', fromNow(new Date('May 08 2020 9:30:00')));
+    });
+  });
+
+  suite('isWithinDay', () => {
+    test('basics works', () => {
+      assert.isTrue(
+        isWithinDay(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')
+        )
+      );
+      assert.isFalse(
+        isWithinDay(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')
+        )
+      );
+    });
+  });
+
+  suite('wasYesterday', () => {
+    test('less 24 hours', () => {
+      assert.isFalse(
+        wasYesterday(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')
+        )
+      );
+      assert.isTrue(
+        wasYesterday(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')
+        )
+      );
+    });
+    test('more 24 hours', () => {
+      assert.isTrue(
+        wasYesterday(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 2:00:00')
+        )
+      );
+      assert.isFalse(
+        wasYesterday(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 06 2020 14:00:00')
+        )
+      );
+    });
+  });
+
+  suite('isWithinHalfYear', () => {
+    test('basics works', () => {
+      assert.isTrue(
+        isWithinHalfYear(
+          new Date('May 08 2020 12:00:00'),
+          new Date('Feb 08 2020 12:00:00')
+        )
+      );
+      assert.isFalse(
+        isWithinHalfYear(
+          new Date('May 08 2020 12:00:00'),
+          new Date('Nov 07 2019 12:00:00')
+        )
+      );
+    });
+  });
+
+  suite('formatDate', () => {
+    test('works for standard format', () => {
+      const stdFormat = 'MMM DD, YYYY';
+      assert.equal(
+        'May 08, 2020',
+        formatDate(new Date('May 08 2020 12:00:00'), stdFormat)
+      );
+      assert.equal(
+        'Feb 28, 2020',
+        formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat)
+      );
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal(
+        'Feb 28, 2020 12:01:12',
+        formatDate(
+          new Date('Feb 28 2020 12:01:12'),
+          stdFormat + ' ' + time24Format
+        )
+      );
+    });
+    test('works for euro format', () => {
+      const euroFormat = 'DD.MM.YYYY';
+      assert.equal(
+        '01.12.2019',
+        formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat)
+      );
+      assert.equal(
+        '20.01.2002',
+        formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat)
+      );
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal(
+        '28.02.2020 00:01:12',
+        formatDate(
+          new Date('Feb 28 2020 00:01:12'),
+          euroFormat + ' ' + time24Format
+        )
+      );
+    });
+    test('works for iso format', () => {
+      const isoFormat = 'YYYY-MM-DD';
+      assert.equal(
+        '2015-01-01',
+        formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat)
+      );
+      assert.equal(
+        '2013-07-03',
+        formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat)
+      );
+
+      const timeFormat = 'h:mm:ss A';
+      assert.equal(
+        '2013-07-03 5:00:00 AM',
+        formatDate(
+          new Date('Jul 03 2013 05:00:00'),
+          isoFormat + ' ' + timeFormat
+        )
+      );
+      assert.equal(
+        '2013-07-03 5:00:00 PM',
+        formatDate(
+          new Date('Jul 03 2013 17:00:00'),
+          isoFormat + ' ' + timeFormat
+        )
+      );
+    });
+    test('h:mm:ss A shows correctly midnight and midday', () => {
+      const timeFormat = 'h:mm A';
+      assert.equal(
+        '12:14 PM',
+        formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat)
+      );
+      assert.equal(
+        '12:15 AM',
+        formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat)
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/display-name-util_test.js b/polygerrit-ui/app/utils/display-name-util_test.js
deleted file mode 100644
index 9bb68dc..0000000
--- a/polygerrit-ui/app/utils/display-name-util_test.js
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getDisplayName, getUserName, getGroupDisplayName, getAccountDisplayName, _testOnly_accountEmail} from './display-name-util.js';
-
-suite('display-name-utils tests', () => {
-  // eslint-disable-next-line no-unused-vars
-  const config = {
-    user: {
-      anonymous_coward_name: 'Anonymous Coward',
-    },
-  };
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.equal(getDisplayName(config, account),
-        'test-name');
-  });
-
-  test('getDisplayName prefer displayName', () => {
-    const account = {
-      name: 'test-name',
-      display_name: 'better-name',
-    };
-    assert.equal(getDisplayName(config, account),
-        'better-name');
-  });
-
-  test('getDisplayName prefer username default', () => {
-    const account = {
-      name: 'test-name',
-      username: 'user-name',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'USERNAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'user-name');
-  });
-
-  test('getDisplayName firstNameOnly', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    assert.equal(getDisplayName(config, account, true), 'firstname');
-  });
-
-  test('getDisplayName prefer first name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName ignore leading whitespace for first name', () => {
-    const account = {
-      name: '   firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName full name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FULL_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname lastname');
-  });
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-name');
-  });
-
-  test('getUserName username only', () => {
-    const account = {
-      username: 'test-user',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-user');
-  });
-
-  test('getUserName email only', () => {
-    const account = {
-      email: 'test-user@test-url.com',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-user@test-url.com');
-  });
-
-  test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.deepEqual(getUserName(config, null),
-        'Anonymous');
-  });
-
-  test('getUserName for the config returning the anon name', () => {
-    const config = {
-      user: {
-        anonymous_coward_name: 'Test Anon',
-      },
-    };
-    assert.deepEqual(getUserName(config, null),
-        'Test Anon');
-  });
-
-  test('getAccountDisplayName - account with name only', () => {
-    assert.equal(
-        getAccountDisplayName(config,
-            {name: 'Some user name'}),
-        'Some user name');
-  });
-
-  test('getAccountDisplayName - account with email only', () => {
-    assert.equal(
-        getAccountDisplayName(config,
-            {email: 'my@example.com'}),
-        'my@example.com <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name and status', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          status: 'OOO',
-        }),
-        'Some name (OOO)');
-  });
-
-  test('getAccountDisplayName - account with name and email', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-        }),
-        'Some name <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name, email and status', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-          status: 'OOO',
-        }),
-        'Some name <my@example.com> (OOO)');
-  });
-
-  test('getGroupDisplayName', () => {
-    assert.equal(
-        getGroupDisplayName({name: 'Some user name'}),
-        'Some user name (group)');
-  });
-
-  test('_accountEmail', () => {
-    assert.equal(
-        _testOnly_accountEmail('email@gerritreview.com'),
-        '<email@gerritreview.com>');
-    assert.equal(_testOnly_accountEmail(undefined), '');
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/display-name-util_test.ts b/polygerrit-ui/app/utils/display-name-util_test.ts
new file mode 100644
index 0000000..e6d4704
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util_test.ts
@@ -0,0 +1,225 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {
+  AccountInfo,
+  DefaultDisplayNameConfig,
+  EmailAddress,
+  GroupName,
+  ServerInfo,
+} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {
+  getDisplayName,
+  getUserName,
+  getGroupDisplayName,
+  getAccountDisplayName,
+  _testOnly_accountEmail,
+} from './display-name-util';
+import {
+  createAccountsConfig,
+  createGroupInfo,
+  createServerInfo,
+} from '../test/test-data-generators';
+
+suite('display-name-utils tests', () => {
+  const config: ServerInfo = {
+    ...createServerInfo(),
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.equal(getDisplayName(config, account), 'test-name');
+  });
+
+  test('getDisplayName prefer displayName', () => {
+    const account = {
+      name: 'test-name',
+      display_name: 'better-name',
+    };
+    assert.equal(getDisplayName(config, account), 'better-name');
+  });
+
+  test('getDisplayName prefer username default', () => {
+    const account = {
+      name: 'test-name',
+      username: 'user-name',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.USERNAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'user-name');
+  });
+
+  test('getDisplayName firstNameOnly', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    assert.equal(getDisplayName(config, account, true), 'firstname');
+  });
+
+  test('getDisplayName prefer first name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FIRST_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname');
+  });
+
+  test('getDisplayName ignore leading whitespace for first name', () => {
+    const account = {
+      name: '   firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FIRST_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname');
+  });
+
+  test('getDisplayName full name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FULL_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname lastname');
+  });
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(getUserName(config, account), 'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(getUserName(config, account), 'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account: AccountInfo = {
+      email: 'test-user@test-url.com' as EmailAddress,
+    };
+    assert.deepEqual(getUserName(config, account), 'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(getUserName(config, undefined), 'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(getUserName(config, undefined), 'Test Anon');
+  });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+      getAccountDisplayName(config, {name: 'Some user name'}),
+      'Some user name'
+    );
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        email: 'my@example.com' as EmailAddress,
+      }),
+      'my@example.com <my@example.com>'
+    );
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        status: 'OOO',
+      }),
+      'Some name (OOO)'
+    );
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+      }),
+      'Some name <my@example.com>'
+    );
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+        status: 'OOO',
+      }),
+      'Some name <my@example.com> (OOO)'
+    );
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+      getGroupDisplayName({
+        ...createGroupInfo(),
+        name: 'Some user name' as GroupName,
+      }),
+      'Some user name (group)'
+    );
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+      _testOnly_accountEmail('email@gerritreview.com'),
+      '<email@gerritreview.com>'
+    );
+    assert.equal(_testOnly_accountEmail(undefined), '');
+  });
+});
diff --git a/polygerrit-ui/app/utils/path-list-util_test.js b/polygerrit-ui/app/utils/path-list-util_test.js
deleted file mode 100644
index 4d06344..0000000
--- a/polygerrit-ui/app/utils/path-list-util_test.js
+++ /dev/null
@@ -1,161 +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 {SpecialFilePath} from '../constants/constants.js';
-import {
-  addUnmodifiedFiles,
-  computeDisplayPath,
-  isMagicPath,
-  specialFilePathCompare, truncatePath,
-} from './path-list-util.js';
-
-suite('path-list-utl tests', () => {
-  test('special sort', () => {
-    const testFiles = [
-      '/a.h',
-      '/MERGE_LIST',
-      '/a.cpp',
-      '/COMMIT_MSG',
-      '/asdasd',
-      '/mrPeanutbutter.py',
-    ];
-    assert.deepEqual(
-        testFiles.sort(specialFilePathCompare),
-        [
-          '/COMMIT_MSG',
-          '/MERGE_LIST',
-          '/a.h',
-          '/a.cpp',
-          '/asdasd',
-          '/mrPeanutbutter.py',
-        ]);
-  });
-
-  test('special file path sorting', () => {
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.a', '.b', 'file']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-            specialFilePathCompare),
-        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
-
-    // Regression test for Issue 4448.
-    assert.deepEqual(
-        [
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_thread_writer.cc',
-          'minidump/minidump_thread_writer.h',
-        ].sort(specialFilePathCompare),
-        [
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_thread_writer.h',
-          'minidump/minidump_thread_writer.cc',
-        ]);
-
-    // Regression test for Issue 4545.
-    assert.deepEqual(
-        [
-          'task_test.go',
-          'task.go',
-        ].sort(specialFilePathCompare),
-        [
-          'task.go',
-          'task_test.go',
-        ]);
-  });
-
-  test('file display name', () => {
-    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
-    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
-    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
-    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
-  });
-
-  test('isMagicPath', () => {
-    assert.isFalse(isMagicPath(undefined));
-    assert.isFalse(isMagicPath('/foo.cc'));
-    assert.isTrue(isMagicPath('/COMMIT_MSG'));
-    assert.isTrue(isMagicPath('/MERGE_LIST'));
-  });
-
-  test('patchset level comments are hidden', () => {
-    const commentedPaths = {
-      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
-      'file1.txt': true,
-    };
-
-    const files = {'file2.txt': {status: 'M'}};
-    addUnmodifiedFiles(files, commentedPaths);
-    assert.equal(files['file1.txt'].status, 'U');
-    assert.equal(files['file2.txt'].status, 'M');
-    assert.isFalse(files.hasOwnProperty(
-        SpecialFilePath.PATCHSET_LEVEL_COMMENTS));
-  });
-
-  test('truncatePath with long path should add ellipsis', () => {
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-
-  test('truncatePath with opt_threshold', () => {
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path, 2);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/level4/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path, 2);
-    assert.equal(shortenedPath, path);
-  });
-
-  test('truncatePath with short path should not add ellipsis', () => {
-    const path = 'file.js';
-    const expectedPath = 'file.js';
-    const shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
new file mode 100644
index 0000000..79b5f09
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -0,0 +1,170 @@
+/**
+ * @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';
+import {FileInfoStatus, SpecialFilePath} from '../constants/constants';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  isMagicPath,
+  specialFilePathCompare,
+  truncatePath,
+} from './path-list-util';
+import {FileInfo} from '../api/rest-api';
+import {hasOwnProperty} from './common-util';
+
+suite('path-list-utl tests', () => {
+  test('special sort', () => {
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(testFiles.sort(specialFilePathCompare), [
+      '/COMMIT_MSG',
+      '/MERGE_LIST',
+      '/a.h',
+      '/a.cpp',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ]);
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', '.a', 'file'].sort(specialFilePathCompare),
+      ['/COMMIT_MSG', '.a', '.b', 'file']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(specialFilePathCompare),
+      ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']
+    );
+
+    // Regression test for Issue 4448.
+    assert.deepEqual(
+      [
+        'minidump/minidump_memory_writer.cc',
+        'minidump/minidump_memory_writer.h',
+        'minidump/minidump_thread_writer.cc',
+        'minidump/minidump_thread_writer.h',
+      ].sort(specialFilePathCompare),
+      [
+        'minidump/minidump_memory_writer.h',
+        'minidump/minidump_memory_writer.cc',
+        'minidump/minidump_thread_writer.h',
+        'minidump/minidump_thread_writer.cc',
+      ]
+    );
+
+    // Regression test for Issue 4545.
+    assert.deepEqual(['task_test.go', 'task.go'].sort(specialFilePathCompare), [
+      'task.go',
+      'task_test.go',
+    ]);
+  });
+
+  test('file display name', () => {
+    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
+    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
+    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    assert.isFalse(isMagicPath(undefined));
+    assert.isFalse(isMagicPath('/foo.cc'));
+    assert.isTrue(isMagicPath('/COMMIT_MSG'));
+    assert.isTrue(isMagicPath('/MERGE_LIST'));
+  });
+
+  test('patchset level comments are hidden', () => {
+    const commentedPaths = {
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
+      'file1.txt': true,
+    };
+
+    const files: {[filename: string]: FileInfo} = {
+      'file2.txt': {
+        status: FileInfoStatus.REWRITTEN,
+        size_delta: 10,
+        size: 10,
+      },
+    };
+    addUnmodifiedFiles(files, commentedPaths);
+    assert.equal(files['file1.txt'].status, FileInfoStatus.UNMODIFIED);
+    assert.equal(files['file2.txt'].status, FileInfoStatus.REWRITTEN);
+    assert.isFalse(
+      hasOwnProperty(files, SpecialFilePath.PATCHSET_LEVEL_COMMENTS)
+    );
+  });
+
+  test('truncatePath with long path should add ellipsis', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+
+  test('truncatePath with opt_threshold', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path, 2);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/level4/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path, 2);
+    assert.equal(shortenedPath, path);
+  });
+
+  test('truncatePath with short path should not add ellipsis', () => {
+    const path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.ts
similarity index 66%
rename from polygerrit-ui/app/utils/url-util_test.js
rename to polygerrit-ui/app/utils/url-util_test.ts
index 5cd4bb4..63dc81d 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -15,7 +15,9 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
+import {ServerInfo} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {createGerritInfo, createServerInfo} from '../test/test-data-generators';
 import {
   getBaseUrl,
   getDocsBaseUrl,
@@ -25,11 +27,13 @@
   toPath,
   toPathname,
   toSearchParams,
-} from './url-util.js';
+} from './url-util';
+import {appContext} from '../services/app-context';
+import {stubRestApi} from '../test/test-utils';
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originalCanonicalPath;
+    let originalCanonicalPath: string | undefined;
 
     suiteSetup(() => {
       originalCanonicalPath = window.CANONICAL_PATH;
@@ -51,43 +55,50 @@
     });
 
     test('null config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const docsBaseUrl = await getDocsBaseUrl(
+        undefined,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.equal(docsBaseUrl, '/Documentation');
     });
 
     test('no doc config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: createGerritInfo(),
       };
-      const config = {gerrit: {}};
-      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const docsBaseUrl = await getDocsBaseUrl(
+        config,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.equal(docsBaseUrl, '/Documentation');
     });
 
     test('has doc config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
       };
-      const config = {gerrit: {doc_url: 'foobar'}};
-      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
-      assert.isFalse(mockRestApi.probePath.called);
+      const docsBaseUrl = await getDocsBaseUrl(
+        config,
+        appContext.restApiService
+      );
+      assert.isFalse(probePathMock.called);
       assert.equal(docsBaseUrl, 'foobar');
     });
 
     test('no probe', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(false)),
-      };
-      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const probePathMock = stubRestApi('probePath').resolves(false);
+      const docsBaseUrl = await getDocsBaseUrl(
+        undefined,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.isNotOk(docsBaseUrl);
     });
   });
@@ -144,7 +155,9 @@
     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');
+    assert.equal(
+      toPath(toPathname('asdf?qwer=zxcv'), toSearchParams('asdf?qwer=zxcv')),
+      'asdf?qwer=zxcv'
+    );
   });
 });