Merge "GerritServer: Propagate index config to pgms"
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/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 7ddf2ba..f476566 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -369,11 +369,11 @@
     if (commonServer != null) {
       try {
         commonServer.close();
-      } catch (Throwable t) {
+      } catch (Exception e) {
         throw new AssertionError(
             "Error stopping common server in "
                 + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
-            t);
+            e);
       } finally {
         commonServer = null;
       }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index ca13db9..d1826bc 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -443,9 +443,6 @@
   /** Globally assigned unique identifier of the change */
   protected Key changeKey;
 
-  /** optimistic locking */
-  protected int rowVersion;
-
   /** When this change was first introduced into the database. */
   protected Timestamp createdOn;
 
@@ -526,7 +523,6 @@
     assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
-    rowVersion = other.rowVersion;
     createdOn = other.createdOn;
     lastUpdatedOn = other.lastUpdatedOn;
     owner = other.owner;
@@ -587,10 +583,6 @@
     lastUpdatedOn = now;
   }
 
-  public int getRowVersion() {
-    return rowVersion;
-  }
-
   public Account.Id getOwner() {
     return owner;
   }
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 25e68f9..689b4aa 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -43,7 +43,6 @@
     Entities.Change.Builder builder =
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
-            .setRowVersion(change.getRowVersion())
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
             .setCreatedOn(change.getCreatedOn().getTime())
             .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
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 7e6ab58..375aa54 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -114,6 +114,7 @@
 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;
@@ -712,50 +713,45 @@
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
-      } catch (RequestCancelledException e) {
-        cause = Optional.of(e);
-        switch (e.getCancellationReason()) {
-          case CLIENT_CLOSED_REQUEST:
-            statusCode = SC_CLIENT_CLOSED_REQUEST;
-            break;
-          case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
-          case SERVER_DEADLINE_EXCEEDED:
-            statusCode = SC_REQUEST_TIMEOUT;
-            break;
-        }
-
-        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
-        if (e.getCancellationMessage().isPresent()) {
-          msg.append("\n\n");
-          msg.append(e.getCancellationMessage().get());
-        }
-
-        responseBytes = replyError(req, res, statusCode, msg.toString(), 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 {
@@ -1961,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/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 5cc8e3c..b727e96 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -36,6 +36,8 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -50,6 +52,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Fake secondary index implementation for usage in tests. All values are kept in-memory.
@@ -179,14 +182,17 @@
   public static class FakeChangeIndex
       extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
     private final ChangeData.Factory changeDataFactory;
+    private final boolean skipMergable;
 
     @Inject
     FakeChangeIndex(
         SitePaths sitePaths,
         ChangeData.Factory changeDataFactory,
-        @Assisted Schema<ChangeData> schema) {
+        @Assisted Schema<ChangeData> schema,
+        @GerritServerConfig Config cfg) {
       super(schema, sitePaths, "changes");
       this.changeDataFactory = changeDataFactory;
+      this.skipMergable = !MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
     }
 
     @Override
@@ -208,6 +214,9 @@
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
       for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+          continue;
+        }
         Object docifiedValue = field.get(value);
         if (docifiedValue != null) {
           doc.put(field.getName(), field.get(value));
diff --git a/java/com/google/gerrit/lifecycle/LifecycleManager.java b/java/com/google/gerrit/lifecycle/LifecycleManager.java
index 4f09a09..42123d7 100644
--- a/java/com/google/gerrit/lifecycle/LifecycleManager.java
+++ b/java/com/google/gerrit/lifecycle/LifecycleManager.java
@@ -107,7 +107,7 @@
       LifecycleListener obj = listeners.get(i).get();
       try {
         obj.stop();
-      } catch (Throwable err) {
+      } catch (RuntimeException err) {
         logger.atWarning().withCause(err).log("Failed to stop %s", obj.getClass());
       }
       startedIndex = i - 1;
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 2b4cfef..a3605f7 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -310,7 +310,7 @@
         RuntimeShutdown.waitFor();
       }
       return 0;
-    } catch (Throwable err) {
+    } catch (RuntimeException err) {
       logger.atSevere().withCause(err).log("Unable to start daemon");
       return 1;
     }
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 89b4228..6f3514f 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -547,7 +547,7 @@
           filterHolder.setInitParameters(initParams);
         }
         app.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
-      } catch (Throwable e) {
+      } catch (Exception e) {
         throw new IllegalArgumentException(
             "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
       }
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index f405c57..e07d148 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -44,7 +44,7 @@
       for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
         try {
           i.next().run();
-        } catch (Throwable err) {
+        } catch (Exception err) {
           logger.atSevere().withCause(err).log("Failed to execute per-request cleanup");
         }
         i.remove();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 50a2f69..435a524 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
@@ -97,12 +98,17 @@
     protected final ExternalIdCache externalIdCache;
     protected final MetricMaker metricMaker;
     protected final AllUsersName allUsersName;
+    protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
 
     protected ExternalIdNotesLoader(
-        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
       this.allUsersName = allUsersName;
+      this.upsertPreprocessors = upsertPreprocessors;
     }
 
     /**
@@ -192,21 +198,24 @@
         ExternalIdCache externalIdCache,
         Provider<AccountIndexer> accountIndexer,
         MetricMaker metricMaker,
-        AllUsersName allUsersName) {
-      super(externalIdCache, metricMaker, allUsersName);
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
+      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors);
       this.accountIndexer = accountIndexer;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .load(rev);
     }
 
     @Override
@@ -220,20 +229,27 @@
 
     @Inject
     FactoryNoReindex(
-        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
-      super(externalIdCache, metricMaker, allUsersName);
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
+      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .setNoReindex()
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .setNoReindex()
+          .load(rev);
     }
 
     @Override
@@ -255,7 +271,8 @@
   public static ExternalIdNotes loadReadOnly(
       AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledMetricMaker(), allUsersName, allUsersRepo, DynamicMap.emptyMap())
         .setReadOnly()
         .setNoCacheUpdate()
         .setNoReindex()
@@ -275,7 +292,8 @@
   public static ExternalIdNotes loadNoCacheUpdate(
       AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledMetricMaker(), allUsersName, allUsersRepo, DynamicMap.emptyMap())
         .setNoCacheUpdate()
         .setNoReindex()
         .load();
@@ -284,6 +302,7 @@
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
+  private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
   private final CallerFinder callerFinder;
 
   private NoteMap noteMap;
@@ -312,13 +331,17 @@
   private boolean noReindex = false;
 
   private ExternalIdNotes(
-      MetricMaker metricMaker, AllUsersName allUsersName, Repository allUsersRepo) {
+      MetricMaker metricMaker,
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+    this.upsertPreprocessors = upsertPreprocessors;
     this.callerFinder =
         CallerFinder.builder()
             // 1. callers that come through ExternalIds
@@ -490,6 +513,7 @@
         (rw, n) -> {
           for (ExternalId extId : extIds) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             newExtIds.add(insertedExtId);
           }
         });
@@ -519,6 +543,7 @@
         (rw, n) -> {
           for (ExternalId extId : extIds) {
             ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(updatedExtId);
             updatedExtIds.add(updatedExtId);
           }
         });
@@ -634,6 +659,7 @@
 
           for (ExternalId extId : toAdd) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -667,6 +693,7 @@
 
           for (ExternalId extId : toAdd) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -809,9 +836,9 @@
   }
 
   /**
-   * Insert or updates an new external ID and sets it in the note map.
+   * Inserts or updates a new external ID and sets it in the note map.
    *
-   * <p>If the external ID already exists it is overwritten.
+   * <p>If the external ID already exists, it is overwritten.
    */
   private static ExternalId upsert(
       RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
@@ -867,6 +894,7 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
+  @Nullable
   private static ExternalId remove(
       RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
@@ -917,6 +945,10 @@
     checkState(noteMap != null, "External IDs not loaded yet");
   }
 
+  private void preprocessUpsert(ExternalId extId) {
+    upsertPreprocessors.forEach(p -> p.get().upsert(extId));
+  }
+
   @FunctionalInterface
   private interface NoteMapUpdate {
     void execute(RevWalk rw, NoteMap noteMap)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
new file mode 100644
index 0000000..c0697db
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * This optional preprocessor is called in {@link ExternalIdNotes} before an update is committed.
+ */
+@ExtensionPoint
+public interface ExternalIdUpsertPreprocessor {
+  /**
+   * Called when inserting or updating an external ID. {@link ExternalId#blobId()} is set. The
+   * upsert can be blocked by throwing {@link com.google.gerrit.exceptions.StorageException}, e.g.
+   * when a precondition or preparatory work fails.
+   */
+  void upsert(ExternalId extId);
+}
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
index 3c668fb..d89701f 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -14,6 +14,7 @@
 
 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;
@@ -22,6 +23,17 @@
 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;
 
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
index e1716eb..683ca1d 100644
--- a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -31,6 +31,7 @@
   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.
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 1bc1fad..d030ec1 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -93,7 +93,7 @@
         try {
           batchAbandon.batchAbandon(updateFactory, project, internalUser, changes, message);
           count += changes.size();
-        } catch (Throwable e) {
+        } catch (Exception e) {
           StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
           for (ChangeData change : changes) {
             msg.append(" ").append(change.getId().get());
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 27b71d6..0d0df0d 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -167,7 +167,6 @@
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
         .putLong(getChange().getLastUpdatedOn().getTime())
-        .putInt(getChange().getRowVersion())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 076ba46..201f122 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -100,6 +100,7 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
@@ -166,6 +167,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 +468,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);
@@ -476,5 +479,6 @@
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
+    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
   }
 }
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 d074f1e..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;
@@ -645,10 +646,18 @@
       try {
         processCommandsUnsafe(commands, progress);
         rejectRemaining(commands, INTERNAL_SERVER_ERROR);
-      } catch (RequestCancelledException e) {
-        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
-        if (e.getCancellationMessage().isPresent()) {
-          msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+      } 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());
       }
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index cae213f..2823548 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -145,7 +145,7 @@
       }
       groupUuids = newGroupUuids;
       logger.atInfo().log("Run group indexer, %s groups reindexed", reindexCounter);
-    } catch (Throwable t) {
+    } catch (Exception t) {
       logger.atSevere().withCause(t).log("Failed to reindex groups");
     }
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 1ee12fe..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;
@@ -1078,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/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index b6dafdc..65e033b15 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -92,7 +92,7 @@
             p -> {
               try (TraceContext traceContext = newPluginTrace(p)) {
                 performanceLogRecords.forEach(r -> r.writeTo(p.get()));
-              } catch (Throwable e) {
+              } catch (RuntimeException e) {
                 logger.atWarning().withCause(e).log(
                     "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
               }
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/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/plugincontext/PluginContext.java b/java/com/google/gerrit/server/plugincontext/PluginContext.java
index 90d56c8..1cfee65 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginContext.java
@@ -204,7 +204,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       pluginMetrics.incrementErrorCount(extension);
       logger.atWarning().withCause(e).log(
           "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
@@ -233,7 +233,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       pluginMetrics.incrementErrorCount(extension);
       logger.atWarning().withCause(e).log(
           "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
@@ -267,7 +267,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
       Throwables.throwIfUnchecked(e);
       pluginMetrics.incrementErrorCount(extension);
@@ -304,7 +304,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
       Throwables.throwIfUnchecked(e);
       pluginMetrics.incrementErrorCount(extension);
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 0a06081..8d17d85 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -253,7 +253,7 @@
           FileSnapshot snapshot = FileSnapshot.save(off.toFile());
           Plugin offPlugin = loadPlugin(name, off, snapshot);
           disabled.put(name, offPlugin);
-        } catch (Throwable e) {
+        } catch (Exception e) {
           // This shouldn't happen, as the plugin was loaded earlier.
           logger.atWarning().withCause(e.getCause()).log(
               "Cannot load disabled plugin %s", active.getName());
@@ -510,7 +510,7 @@
       if (!newPlugin.isDisabled()) {
         try {
           newPlugin.start(env);
-        } catch (Throwable e) {
+        } catch (Exception e) {
           newPlugin.stop(env);
           throw e;
         }
@@ -528,7 +528,7 @@
       }
       broken.remove(name);
       return newPlugin;
-    } catch (Throwable err) {
+    } catch (Exception err) {
       broken.put(name, snapshot);
       throw new PluginInstallException(err);
     }
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/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index f912250..8b4e4c7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -38,7 +38,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -106,6 +105,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 +932,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
@@ -1271,12 +1286,6 @@
     return refStates;
   }
 
-  @UsedAt(UsedAt.Project.GOOGLE)
-  public void setRefStates(Iterable<byte[]> refStates) {
-    // TODO(hanwen): remove Google use, and drop this method.
-    setRefStates(RefState.parseStates(refStates));
-  }
-
   public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
     this.refStates = refStates;
     if (draftsByUser == null) {
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 1a563ad..7d626da 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -143,7 +143,7 @@
     for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
       try {
         i.next().run();
-      } catch (Throwable err) {
+      } catch (Exception err) {
         logger.atSevere().withCause(err).log("Failed to execute cleanup for PrologEnvironment");
       }
       i.remove();
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/update/RetryableChangeAction.java b/java/com/google/gerrit/server/update/RetryableChangeAction.java
index 152db2c..84ec2bb 100644
--- a/java/com/google/gerrit/server/update/RetryableChangeAction.java
+++ b/java/com/google/gerrit/server/update/RetryableChangeAction.java
@@ -82,11 +82,11 @@
   public T call() throws UpdateException, RestApiException {
     try {
       return super.call();
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      Throwables.throwIfInstanceOf(t, UpdateException.class);
-      Throwables.throwIfInstanceOf(t, RestApiException.class);
-      throw new UpdateException(t);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, UpdateException.class);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
index cf733a6..d66edcf 100644
--- a/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
+++ b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
@@ -87,9 +87,9 @@
   public T call() {
     try {
       return super.call();
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      throw new StorageException(t);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new StorageException(e);
     }
   }
 }
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/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 48a5512..42aabfb 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -370,7 +370,7 @@
         err.flush();
       } catch (IOException e2) {
         // Ignored
-      } catch (Throwable e2) {
+      } catch (RuntimeException e2) {
         logger.atWarning().withCause(e2).log("Cannot send failure message to client");
       }
       return f.exitCode;
@@ -381,7 +381,7 @@
       err.flush();
     } catch (IOException e2) {
       // Ignored
-    } catch (Throwable e2) {
+    } catch (RuntimeException e2) {
       logger.atWarning().withCause(e2).log("Cannot send internal server error message to client");
     }
     return 128;
@@ -500,15 +500,15 @@
 
           out.flush();
           err.flush();
-        } catch (Throwable e) {
+        } catch (Exception e) {
           try {
             out.flush();
-          } catch (Throwable e2) {
+          } catch (Exception e2) {
             // Ignored
           }
           try {
             err.flush();
-          } catch (Throwable e2) {
+          } catch (Exception e2) {
             // Ignored
           }
           rc = handleError(e);
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 93c6c2c..e58040f 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,6 +14,7 @@
 
 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;
@@ -28,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;
@@ -62,10 +64,18 @@
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
               requestListeners.runEach(l -> l.onRequest(requestInfo));
               SshCommand.this.run();
-            } catch (RequestCancelledException e) {
-              StringBuilder msg = new StringBuilder(e.formatCancellationReason());
-              if (e.getCancellationMessage().isPresent()) {
-                msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+            } 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 {
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 773c25b..d5f0ee8 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -138,7 +138,7 @@
         // to do with the key object, and instead we must abort this load.
         //
         throw e;
-      } catch (Throwable e) {
+      } catch (Exception e) {
         markInvalid(k);
       }
     }
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 0eda433..c1f4a7b 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -214,16 +214,16 @@
       } catch (GitAPIException e) {
         throw new Failure(7, "fatal: git api exception, " + e);
       }
-    } catch (Throwable t) {
+    } catch (Exception e) {
       // Report the error in ERROR sideband channel. Catch Throwable too so we can also catch
       // NoClassDefFound.
       try (SideBandOutputStream sidebandError =
           new SideBandOutputStream(
               SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) {
-        sidebandError.write(t.getMessage().getBytes(UTF_8));
+        sidebandError.write(e.getMessage().getBytes(UTF_8));
         sidebandError.flush();
       }
-      throw t;
+      throw e;
     } finally {
       // In any case, cleanly close the packetOut channel
       packetOut.end();
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 06d9453..12da2c1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -134,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);
@@ -240,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/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/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
index 29d54cc..0713fe6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -121,6 +121,27 @@
   }
 
   @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() {
@@ -185,6 +206,27 @@
   }
 
   @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() {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
new file mode 100644
index 0000000..64ad900
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
@@ -0,0 +1,209 @@
+// 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.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests {@link ExternalIdUpsertPreprocessor}. */
+@TestPlugin(
+    name = "external-id-update-preprocessor",
+    sysModule =
+        "com.google.gerrit.acceptance.server.notedb.ExternalIdNotesUpsertPreprocessorIT$Module")
+public class ExternalIdNotesUpsertPreprocessorIT extends LightweightPluginDaemonTest {
+  @Inject private Sequences sequences;
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+  @Inject private Accounts accounts;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExternalIdUpsertPreprocessor.class)
+          .annotatedWith(Exports.named("TestPreprocessor"))
+          .to(TestPreprocessor.class);
+    }
+  }
+
+  private TestPreprocessor testPreprocessor;
+
+  @Before
+  public void setUp() {
+    testPreprocessor = plugin.getSysInjector().getInstance(TestPreprocessor.class);
+    testPreprocessor.reset();
+  }
+
+  @Test
+  public void insertAccount() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId));
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replaceByKeys() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar1", id);
+    ExternalId extId2 = ExternalId.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replaceByKeys(ImmutableSet.of(extId1.key()), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void insert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.insert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void upsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.upsert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar1", id);
+    ExternalId extId2 = ExternalId.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void replace_viaAccountsUpdate() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = ExternalId.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    accountsUpdateProvider.get().update("test", id, u -> u.updateExternalId(extId2));
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void blockUpsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+    testPreprocessor.throwException = true;
+    StorageException e =
+        assertThrows(
+            StorageException.class,
+            () -> accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId)));
+    assertThat(e).hasMessageThat().contains("upsert not good");
+    assertThat(testPreprocessor.upserted).isEmpty();
+  }
+
+  @Test
+  public void blockUpsert_replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = ExternalId.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+
+    testPreprocessor.reset();
+    testPreprocessor.throwException = true;
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      StorageException e = assertThrows(StorageException.class, () -> extIdNotes.commit(md));
+      assertThat(e).hasMessageThat().contains("upsert not good");
+    }
+    assertThat(testPreprocessor.upserted).isEmpty();
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+  }
+
+  @Singleton
+  public static class TestPreprocessor implements ExternalIdUpsertPreprocessor {
+    List<ExternalId> upserted = new ArrayList<>();
+
+    boolean throwException = false;
+
+    @Override
+    public void upsert(ExternalId extId) {
+      assertThat(extId.blobId()).isNotNull();
+      if (throwException) {
+        throw new StorageException("upsert not good");
+      }
+      upserted.add(extId);
+    }
+
+    void reset() {
+      upserted.clear();
+      throwException = false;
+    }
+  }
+}
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
index 2cb9637..3e31c16 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -99,4 +99,22 @@
       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/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index ae8e06d..8c5e449 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -60,7 +60,6 @@
         Entities.Change.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(14))
             .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
             .setCreatedOn(987654L)
             .setLastUpdatedOn(1234567L)
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
@@ -109,7 +108,6 @@
                     .setBranch("refs/heads/branch-74"))
             // Default values which can't be unset.
             .setCurrentPatchSetId(0)
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -147,7 +145,6 @@
                     .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(0)
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -185,7 +182,6 @@
             .setCurrentPatchSetId(23)
             .setSubject("subject ABC")
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -251,7 +247,6 @@
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
-    assertThat(change.getRowVersion()).isEqualTo(0);
     assertThat(change.isNew()).isTrue();
     assertThat(change.isPrivate()).isFalse();
     assertThat(change.isWorkInProgress()).isFalse();
@@ -284,7 +279,6 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("rowVersion", int.class)
                 .put("createdOn", Timestamp.class)
                 .put("lastUpdatedOn", Timestamp.class)
                 .put("owner", Account.Id.class)
@@ -309,7 +303,6 @@
   private static void assertEqualChange(Change change, Change expectedChange) {
     assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
     assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
-    assertThat(change.getRowVersion()).isEqualTo(expectedChange.getRowVersion());
     assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
     assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
     assertThat(change.getOwner()).isEqualTo(expectedChange.getOwner());
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/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 287a7fe..10599c6 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -64,6 +64,7 @@
             cfg.setInt("change", null, "maxFiles", 2);
             cfg.setInt("change", null, "maxPatchSets", MAX_PATCH_SETS);
             cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            cfg.setString("index", null, "type", "fake");
             return cfg;
           });
 
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 4d0bb1f..6a64126 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -95,9 +95,6 @@
     "elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts",
     "elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts",
     "elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts",
-    "elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts",
-    "elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts",
-    "elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts",
     "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
     "elements/change/gr-file-list/gr-file-list_html.ts",
     "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
@@ -106,10 +103,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/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",
     "elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts",
@@ -124,9 +117,7 @@
     "elements/shared/gr-autocomplete/gr-autocomplete_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-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",
@@ -135,7 +126,6 @@
     "elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts",
     "elements/shared/gr-list-view/gr-list-view_html.ts",
     "elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts",
-    "elements/shared/gr-textarea/gr-textarea_html.ts",
 ]
 
 # Transform templates into a .ts files.
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-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 4e6c963..77b7717 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -27,8 +27,9 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 
-interface RebaseChange {
+export interface RebaseChange {
   name: string;
   value: NumericChangeId;
 }
@@ -39,10 +40,15 @@
 
 export interface GrConfirmRebaseDialog {
   $: {
+    confirmDialog: GrDialog;
     parentInput: GrAutocomplete;
+    parentUpToDateMsg: HTMLDivElement;
+    rebaseOnParent: HTMLDivElement;
     rebaseOnParentInput: HTMLInputElement;
     rebaseOnOtherInput: HTMLInputElement;
+    rebaseOnTip: HTMLDivElement;
     rebaseOnTipInput: HTMLInputElement;
+    tipUpToDateMsg: HTMLDivElement;
   };
 }
 
@@ -77,10 +83,10 @@
   rebaseOnCurrent?: boolean;
 
   @property({type: String})
-  _text?: string;
+  _text = '';
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: Array})
   _recentChanges?: RebaseChange[];
@@ -146,15 +152,15 @@
       );
   }
 
-  _displayParentOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayParentOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return hasParent && rebaseOnCurrent;
   }
 
-  _displayParentUpToDateMsg(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayParentUpToDateMsg(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return hasParent && !rebaseOnCurrent;
   }
 
-  _displayTipOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayTipOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return !(!rebaseOnCurrent && !hasParent);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index bed9240..1052201 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -79,7 +79,6 @@
           name="rebaseOptions"
           type="radio"
           disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-          on-click="_handleRebaseOnTip"
         />
         <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
           Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
similarity index 67%
rename from polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 0faf604..74c1b3c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -15,14 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-rebase-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-confirm-rebase-dialog';
+import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
+import {stubRestApi} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {NumericChangeId} from '../../../types/common';
+import {createChangeViewChange} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
 
 suite('gr-confirm-rebase-dialog tests', () => {
-  let element;
+  let element: GrConfirmRebaseDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -75,16 +79,20 @@
   test('input cleared on cancel or submit', () => {
     element._text = '123';
     element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('confirm', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
     assert.equal(element._text, '');
 
     element._text = '123';
     element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: true,
+      })
+    );
     assert.equal(element._text, '');
   });
 
@@ -102,55 +110,59 @@
   });
 
   suite('parent suggestions', () => {
-    let recentChanges;
-    let getChangesStub;
+    let recentChanges: RebaseChange[];
+    let getChangesStub: sinon.SinonStub;
     setup(() => {
       recentChanges = [
         {
           name: '123: my first awesome change',
-          value: 123,
+          value: 123 as NumericChangeId,
         },
         {
           name: '124: my second awesome change',
-          value: 124,
+          value: 124 as NumericChangeId,
         },
         {
           name: '245: my third awesome change',
-          value: 245,
+          value: 245 as NumericChangeId,
         },
       ];
 
-      getChangesStub = stubRestApi('getChanges').returns(Promise.resolve(
-          [
-            {
-              _number: 123,
-              subject: 'my first awesome change',
-            },
-            {
-              _number: 124,
-              subject: 'my second awesome change',
-            },
-            {
-              _number: 245,
-              subject: 'my third awesome change',
-            },
-          ]
-      ));
+      getChangesStub = stubRestApi('getChanges').returns(
+        Promise.resolve([
+          {
+            ...createChangeViewChange(),
+            _number: 123 as NumericChangeId,
+            subject: 'my first awesome change',
+          },
+          {
+            ...createChangeViewChange(),
+            _number: 124 as NumericChangeId,
+            subject: 'my second awesome change',
+          },
+          {
+            ...createChangeViewChange(),
+            _number: 245 as NumericChangeId,
+            subject: 'my third awesome change',
+          },
+        ])
+      );
     });
 
     test('_getRecentChanges', () => {
-      sinon.spy(element, '_getRecentChanges');
-      return element._getRecentChanges()
-          .then(() => {
-            assert.deepEqual(element._recentChanges, recentChanges);
-            assert.equal(getChangesStub.callCount, 1);
-            // When called a second time, should not re-request recent changes.
-            element._getRecentChanges();
-          })
-          .then(() => {
-            assert.equal(element._getRecentChanges.callCount, 2);
-            assert.equal(getChangesStub.callCount, 1);
-          });
+      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
+      return element
+        ._getRecentChanges()
+        .then(() => {
+          assert.deepEqual(element._recentChanges, recentChanges);
+          assert.equal(getChangesStub.callCount, 1);
+          // When called a second time, should not re-request recent changes.
+          element._getRecentChanges();
+        })
+        .then(() => {
+          assert.equal(recentChangesSpy.callCount, 2);
+          assert.equal(getChangesStub.callCount, 1);
+        });
     });
 
     test('_filterChanges', () => {
@@ -159,25 +171,25 @@
       assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
       assert.equal(element._filterChanges('third', recentChanges).length, 1);
 
-      element.changeNumber = 123;
+      element.changeNumber = 123 as NumericChangeId;
       assert.equal(element._filterChanges('123', recentChanges).length, 0);
       assert.equal(element._filterChanges('124', recentChanges).length, 1);
       assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
     });
 
     test('input text change triggers function', () => {
-      sinon.spy(element, '_getRecentChanges');
+      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
       element.$.parentInput.noDebounce = true;
       MockInteractions.pressAndReleaseKeyOn(
-          element.$.parentInput.$.input,
-          13,
-          null,
-          'enter');
+        element.$.parentInput.$.input,
+        13,
+        null,
+        'enter'
+      );
       element._text = '1';
-      assert.isTrue(element._getRecentChanges.calledOnce);
+      assert.isTrue(recentChangesSpy.calledOnce);
       element._text = '12';
-      assert.isTrue(element._getRecentChanges.calledTwice);
+      assert.isTrue(recentChangesSpy.calledTwice);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 450551b..b971039 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -59,7 +59,7 @@
   /* The revert message updated by the user
       The default value is set by the dialog */
   @property({type: String})
-  _message?: string;
+  _message = '';
 
   @property({type: Number})
   _revertType = RevertType.REVERT_SINGLE_CHANGE;
@@ -198,7 +198,7 @@
     this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (this._message === this._originalRevertMessages[this._revertType]) {
@@ -218,7 +218,7 @@
     );
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
index 3ec4f2c..b2acff2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -85,8 +85,8 @@
           <label for="revertSubmission" class="label revertSubmission">
             Revert entire submission ([[_changesCount]] Changes)
           </label>
-        </div></template
-      >
+        </div>
+      </template>
       <gr-endpoint-decorator name="confirm-revert-change">
         <label for="messageInput"> Revert Commit Message </label>
         <iron-autogrow-textarea
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 33f7304..bd8eaac 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -85,19 +85,20 @@
     return commentThreads.filter(thread => isUnresolved(thread));
   }
 
-  _computeUnresolvedCommentsWarning(change: ChangeInfo) {
+  _computeUnresolvedCommentsWarning(change?: ChangeInfo) {
+    if (!change) return '';
     const unresolvedCount = change.unresolved_comment_count;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
     return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
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 5206fdb..f4f37c1 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
@@ -82,13 +82,9 @@
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {preferences$} from '../../../services/user/user-model';
-import {
-  changeComments$,
-  drafts$,
-} from '../../../services/comments/comments-model';
+import {changeComments$} from '../../../services/comments/comments-model';
 import {Subject} from 'rxjs';
 import {takeUntil} from 'rxjs/operators';
-import {UIDraft} from '../../../utils/comment-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -316,9 +312,6 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
-  @property({type: Object})
-  diffDrafts?: {[path: string]: UIDraft[]} = {};
-
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
@@ -373,9 +366,6 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
-    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
-      this.diffDrafts = drafts;
-    });
     changeComments$
       .pipe(takeUntil(this.disconnected$))
       .subscribe(changeComments => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 5e6b076..d5c0171 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -33,6 +33,7 @@
   AccountDetailInfo,
   AccountInfo,
   ChangeInfo,
+  NumericChangeId,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {
@@ -82,7 +83,7 @@
   threads: CommentThread[] = [];
 
   @property({type: String})
-  changeNum?: string;
+  changeNum?: NumericChangeId;
 
   @property({type: Boolean})
   loggedIn?: boolean;
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/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 5cd7bfc..e60c2bb 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -97,7 +97,7 @@
       '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
       '_patchNum)',
   })
-  _disableApplyFixButton?: boolean;
+  _disableApplyFixButton = false;
 
   layers = appContext.flagsService.isEnabled(
     KnownExperimentId.TOKEN_HIGHLIGHTING
@@ -187,12 +187,13 @@
     return (_fixSuggestions || []).length === 1;
   }
 
-  overridePartialPrefs(prefs: DiffPreferencesInfo): DiffPreferencesInfo {
+  overridePartialPrefs(prefs?: DiffPreferencesInfo) {
+    if (!prefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
     return {...prefs, line_length: 50};
   }
 
-  onCancel(e: CustomEvent) {
+  onCancel(e: Event) {
     if (e) {
       e.stopPropagation();
     }
@@ -203,7 +204,7 @@
     return _selectedFixIdx + 1;
   }
 
-  _onPrevFixClick(e: CustomEvent) {
+  _onPrevFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
       this._selectedFixIdx -= 1;
@@ -213,7 +214,7 @@
     }
   }
 
-  _onNextFixClick(e: CustomEvent) {
+  _onNextFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (
       this._fixSuggestions &&
@@ -257,7 +258,7 @@
   }
 
   _computeDisableApplyFixButton(
-    isApplyFixLoading?: boolean,
+    isApplyFixLoading: boolean,
     change?: ParsedChangeInfo,
     patchNum?: PatchSetNum
   ) {
@@ -271,7 +272,7 @@
     return isApplyFixLoading;
   }
 
-  _handleApplyFix(e: CustomEvent) {
+  _handleApplyFix(e: Event) {
     if (e) {
       e.stopPropagation();
     }
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/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index fdde355..888e514 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -40,7 +40,12 @@
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
-import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {
+  BlameInfo,
+  CommentRange,
+  ImageInfo,
+  NumericChangeId,
+} from '../../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
@@ -155,7 +160,7 @@
    */
 
   @property({type: String})
-  changeNum?: string;
+  changeNum?: NumericChangeId;
 
   @property({type: Boolean})
   noAutoRender = false;
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 5b248da..98956c9 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
@@ -63,6 +63,7 @@
     table {
       border-collapse: collapse;
       table-layout: fixed;
+      background-color: var(--diff-blank-background-color);
     }
 
     /*
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index da75914..16d54dd 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -28,7 +28,7 @@
   GenerateUrlEditViewParameters,
 } from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, observe, property} from '@polymer/decorators';
 import {
   ChangeInfo,
   PatchSetNum,
@@ -211,8 +211,8 @@
   }
 
   _editChange(value?: ChangeInfo | null) {
-    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     if (!value) return;
+    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     fireAlert(
       this,
       'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
@@ -220,6 +220,15 @@
     GerritNav.navigateToChange(value);
   }
 
+  @observe('_change', '_type')
+  _editType(change?: ChangeInfo | null, type?: string) {
+    if (!change || !type || !type.startsWith('image/')) return;
+
+    // Prevent editing binary files
+    fireAlert(this, 'You cannot edit binary files within the inline editor.');
+    GerritNav.navigateToChange(change);
+  }
+
   _handlePathChanged(e: CustomEvent<string>) {
     // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 4909bef..7cfd1b3 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -35,6 +35,7 @@
     showMatchBrackets: HTMLInputElement;
     editShowLineWrapping: HTMLInputElement;
     editShowTabs: HTMLInputElement;
+    editShowTrailingWhitespaceInput: HTMLInputElement;
   };
 }
 @customElement('gr-edit-preferences')
@@ -89,6 +90,14 @@
     this._handleEditPrefsChanged();
   }
 
+  _handleEditShowTrailingWhitespaceTap() {
+    this.set(
+      'editPrefs.show_whitespace_errors',
+      this.$.editShowTrailingWhitespaceInput.checked
+    );
+    this._handleEditPrefsChanged();
+  }
+
   _handleMatchBracketsChanged() {
     this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
     this._handleEditPrefsChanged();
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
index a51deaf..abd925f 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -85,6 +85,19 @@
       </span>
     </section>
     <section>
+      <label for="showTrailingWhitespaceInput" class="title"
+        >Show trailing whitespace</label
+      >
+      <span class="value">
+        <input
+          id="editShowTrailingWhitespaceInput"
+          type="checkbox"
+          checked$="[[editPrefs.show_whitespace_errors]]"
+          on-change="_handleEditShowTrailingWhitespaceTap"
+        />
+      </span>
+    </section>
+    <section>
       <label for="showMatchBrackets" class="title">Match brackets</label>
       <span class="value">
         <input
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 73d1bf0..4d2aee7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -39,7 +39,7 @@
   }
 }
 
-interface Item {
+export interface Item {
   dataValue?: string;
   name?: string;
   text?: string;
@@ -47,6 +47,11 @@
   value?: string;
 }
 
+export interface ItemSelectedEvent {
+  trigger: string;
+  selected: HTMLElement | null;
+}
+
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends IronFitMixin(
   KeyboardShortcutMixin(PolymerElement),
@@ -155,7 +160,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'tab',
           selected: this.cursor.target,
@@ -170,7 +175,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'enter',
           selected: this.cursor.target,
@@ -189,7 +194,7 @@
   _handleClickItem(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    let selected = e.target! as Element;
+    let selected = e.target! as HTMLElement;
     while (!selected.classList.contains('autocompleteOption')) {
       if (!selected || selected === this) {
         return;
@@ -197,7 +202,7 @@
       selected = selected.parentElement!;
     }
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'click',
           selected,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 2ad3be6..65a328c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -225,18 +225,6 @@
     this.addEventListener('comment-update', e =>
       this._handleCommentUpdate(e as CustomEvent)
     );
-    // Wait for comment to be rendered before scrolling to it
-    if (this.shouldScrollIntoView) {
-      const resizeObserver = new ResizeObserver(
-        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
-          if (this.offsetHeight > 0) {
-            this.scrollIntoView();
-            observer.unobserve(this);
-          }
-        }
-      );
-      resizeObserver.observe(this);
-    }
   }
 
   /** @override */
@@ -537,11 +525,16 @@
    * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
    * thread is unresolved,
    * - it's a robot comment.
+   * - it's a draft
    */
   _setInitialExpandedState() {
     if (this._orderedComments) {
       for (let i = 0; i < this._orderedComments.length; i++) {
         const comment = this._orderedComments[i];
+        if (isDraft(comment)) {
+          comment.collapsed = false;
+          continue;
+        }
         const isRobotComment = !!(comment as UIRobot).robot_id;
         // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
         const resolvedThread =
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 0f071abb..0a9b9c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -54,13 +54,13 @@
    */
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   resetFocus() {
     this.$.messageInput.textarea.focus();
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -72,7 +72,7 @@
     );
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 70a7bf3..e560773 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -21,18 +21,23 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences_html';
 import {customElement, property} from '@polymer/decorators';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 import {appContext} from '../../../services/app-context';
 
 export interface GrDiffPreferences {
   $: {
+    contextLineSelect: HTMLInputElement;
+    columnsInput: HTMLInputElement;
+    tabSizeInput: HTMLInputElement;
+    fontSizeInput: HTMLInputElement;
     lineWrappingInput: HTMLInputElement;
     showTabsInput: HTMLInputElement;
     showTrailingWhitespaceInput: HTMLInputElement;
     automaticReviewInput: HTMLInputElement;
     syntaxHighlightInput: HTMLInputElement;
     contextSelect: GrSelect;
+    ignoreWhiteSpace: HTMLInputElement;
   };
   save(): Promise<void>;
 }
@@ -61,11 +66,31 @@
     this.hasUnsavedChanges = true;
   }
 
+  _handleDiffContextChanged() {
+    this.set('diffPrefs.context', Number(this.$.contextLineSelect.value));
+    this._handleDiffPrefsChanged();
+  }
+
   _handleLineWrappingTap() {
     this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
     this._handleDiffPrefsChanged();
   }
 
+  _handleDiffLineLengthChanged() {
+    this.set('diffPrefs.line_length', Number(this.$.columnsInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleDiffTabSizeChanged() {
+    this.set('diffPrefs.tab_size', Number(this.$.tabSizeInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleDiffFontSizeChanged() {
+    this.set('diffPrefs.font_size', Number(this.$.fontSizeInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
   _handleShowTabsTap() {
     this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
     this._handleDiffPrefsChanged();
@@ -92,6 +117,14 @@
     this._handleDiffPrefsChanged();
   }
 
+  _handleDiffIgnoreWhitespaceChanged() {
+    this.set(
+      'diffPrefs.ignore_whitespace',
+      this.$.ignoreWhiteSpace.value as IgnoreWhitespaceType
+    );
+    this._handleDiffPrefsChanged();
+  }
+
   save() {
     if (!this.diffPrefs)
       return Promise.reject(new Error('Missing diff preferences'));
@@ -99,6 +132,27 @@
       this.hasUnsavedChanges = false;
     });
   }
+
+  /**
+   * bind-value has type string so we have to convert
+   * anything inputed to string.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToString(key?: number | IgnoreWhitespaceType) {
+    return key !== undefined ? String(key) : '';
+  }
+
+  /**
+   * input 'checked' does not allow undefined,
+   * so we make sure the value is boolean
+   * by returning false if undefined.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToBoolean(key?: boolean) {
+    return key !== undefined ? key : false;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
index ed3d695..51867c8 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -27,12 +27,12 @@
     <section>
       <label for="contextLineSelect" class="title">Context</label>
       <span class="value">
-        <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
-          <select
-            id="contextLineSelect"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          >
+        <gr-select
+          id="contextSelect"
+          bind-value="[[_convertToString(diffPrefs.context)]]"
+          on-change="_handleDiffContextChanged"
+        >
+          <select id="contextLineSelect">
             <option value="3">3 lines</option>
             <option value="10">10 lines</option>
             <option value="25">25 lines</option>
@@ -50,7 +50,7 @@
         <input
           id="lineWrappingInput"
           type="checkbox"
-          checked="[[diffPrefs.line_wrapping]]"
+          checked="[[_convertToBoolean(diffPrefs.line_wrapping)]]"
           on-change="_handleLineWrappingTap"
         />
       </span>
@@ -59,23 +59,11 @@
       <label for="columnsInput" class="title">Diff width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.line_length}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.line_length)]]"
+          on-change="_handleDiffLineLengthChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="columnsInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.line_length}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="columnsInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -83,23 +71,11 @@
       <label for="tabSizeInput" class="title">Tab width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.tab_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.tab_size)]]"
+          on-change="_handleDiffTabSizeChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="tabSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.tab_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="tabSizeInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -107,23 +83,11 @@
       <label for="fontSizeInput" class="title">Font size</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.font_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.font_size)]]"
+          on-change="_handleDiffFontSizeChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="fontSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.font_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="fontSizeInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -133,7 +97,7 @@
         <input
           id="showTabsInput"
           type="checkbox"
-          checked="[[diffPrefs.show_tabs]]"
+          checked="[[_convertToBoolean(diffPrefs.show_tabs)]]"
           on-change="_handleShowTabsTap"
         />
       </span>
@@ -146,7 +110,7 @@
         <input
           id="showTrailingWhitespaceInput"
           type="checkbox"
-          checked="[[diffPrefs.show_whitespace_errors]]"
+          checked="[[_convertToBoolean(diffPrefs.show_whitespace_errors)]]"
           on-change="_handleShowTrailingWhitespaceTap"
         />
       </span>
@@ -159,7 +123,7 @@
         <input
           id="syntaxHighlightInput"
           type="checkbox"
-          checked="[[diffPrefs.syntax_highlighting]]"
+          checked="[[_convertToBoolean(diffPrefs.syntax_highlighting)]]"
           on-change="_handleSyntaxHighlightTap"
         />
       </span>
@@ -172,7 +136,7 @@
         <input
           id="automaticReviewInput"
           type="checkbox"
-          checked="[[!diffPrefs.manual_review]]"
+          checked="[[!_convertToBoolean(diffPrefs.manual_review)]]"
           on-change="_handleAutomaticReviewTap"
         />
       </span>
@@ -181,12 +145,11 @@
       <div class="pref">
         <label for="ignoreWhiteSpace" class="title">Ignore Whitespace</label>
         <span class="value">
-          <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
-            <select
-              id="ignoreWhiteSpace"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged"
-            >
+          <gr-select
+            bind-value="[[_convertToString(diffPrefs.ignore_whitespace)]]"
+            on-change="_handleDiffIgnoreWhitespaceChanged"
+          >
+            <select id="ignoreWhiteSpace">
               <option value="IGNORE_NONE">None</option>
               <option value="IGNORE_TRAILING">Trailing</option>
               <option value="IGNORE_LEADING_AND_TRAILING">
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
deleted file mode 100644
index 716ef2f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
+++ /dev/null
@@ -1,105 +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-diff-preferences.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences');
-
-suite('gr-diff-preferences tests', () => {
-  let element;
-
-  let diffPreferences;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(() => {
-    diffPreferences = {
-      context: 10,
-      line_wrapping: false,
-      line_length: 100,
-      tab_size: 8,
-      font_size: 12,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      manual_review: false,
-      ignore_whitespace: 'IGNORE_NONE',
-    };
-
-    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
-
-    element = basicFixture.instantiate();
-
-    return element.loadData();
-  });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Context', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.context);
-    assert.equal(valueOf('Fit to screen', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.line_wrapping);
-    assert.equal(valueOf('Diff width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.line_length);
-    assert.equal(valueOf('Tab width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.tab_size);
-    assert.equal(valueOf('Font size', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.font_size);
-    assert.equal(valueOf('Show tabs', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_tabs);
-    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.syntax_highlighting);
-    assert.equal(
-        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
-            .firstElementChild.checked, !diffPreferences.manual_review);
-    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    stubRestApi('saveDiffPreferences')
-        .returns(Promise.resolve());
-    const showTrailingWhitespaceCheckbox =
-        valueOf('Show trailing whitespace', 'diffPreferences')
-            .firstElementChild;
-    showTrailingWhitespaceCheckbox.checked = false;
-    element._handleShowTrailingWhitespaceTap();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
new file mode 100644
index 0000000..6c1404e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -0,0 +1,134 @@
+/**
+ * @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-diff-preferences';
+import {GrDiffPreferences} from './gr-diff-preferences';
+import {stubRestApi} from '../../../test/test-utils';
+import {DiffPreferencesInfo} from '../../../types/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {IronInputElement} from '@polymer/iron-input';
+import {GrSelect} from '../gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences');
+
+suite('gr-diff-preferences tests', () => {
+  let element: GrDiffPreferences;
+
+  let diffPreferences: DiffPreferencesInfo;
+
+  function valueOf(title: string, id: string) {
+    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl?.textContent?.trim() === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
+      }
+    }
+    assert.fail(`element with title ${title} not found`);
+  }
+
+  setup(async () => {
+    diffPreferences = createDefaultDiffPrefs();
+
+    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    const contextInput = valueOf('Context', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(contextInput.bindValue, `${diffPreferences.context}`);
+
+    const lineWrappingInput = valueOf('Fit to screen', 'diffPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(lineWrappingInput.checked, diffPreferences.line_wrapping);
+
+    const lineLengthInput = valueOf('Diff width', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(lineLengthInput.bindValue, `${diffPreferences.line_length}`);
+
+    const tabSizeInput = valueOf('Tab width', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(tabSizeInput.bindValue, `${diffPreferences.tab_size}`);
+
+    const fontSizeInput = valueOf('Font size', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(fontSizeInput.bindValue, `${diffPreferences.font_size}`);
+
+    const showTabsInput = valueOf('Show tabs', 'diffPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(showTabsInput.checked, diffPreferences.show_tabs);
+
+    const showWhitespaceErrorsInput = valueOf(
+      'Show trailing whitespace',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(
+      showWhitespaceErrorsInput.checked,
+      diffPreferences.show_whitespace_errors
+    );
+
+    const syntaxHighlightingInput = valueOf(
+      'Syntax highlighting',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(
+      syntaxHighlightingInput.checked,
+      diffPreferences.syntax_highlighting
+    );
+
+    const manualReviewInput = valueOf(
+      'Automatically mark viewed files reviewed',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(manualReviewInput.checked, !diffPreferences.manual_review);
+
+    const ignoreWhitespaceInput = valueOf(
+      'Ignore Whitespace',
+      'diffPreferences'
+    ).firstElementChild as GrSelect;
+    assert.equal(
+      ignoreWhitespaceInput.bindValue,
+      diffPreferences.ignore_whitespace
+    );
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', async () => {
+    const showTrailingWhitespaceCheckbox = valueOf(
+      'Show trailing whitespace',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    showTrailingWhitespaceCheckbox.checked = false;
+    element._handleShowTrailingWhitespaceTap();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index a747ac4..17c46c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -28,7 +28,12 @@
 import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  GrAutocompleteDropdown,
+  Item,
+  ItemSelectedEvent,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {CustomKeyboardEvent} from '../../../types/events';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -56,11 +61,8 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
-interface EmojiSuggestion {
-  value: string;
+interface EmojiSuggestion extends Item {
   match: string;
-  dataValue?: string;
-  text?: string;
 }
 
 interface ValueChangeEvent {
@@ -76,6 +78,13 @@
   };
 }
 
+declare global {
+  interface HTMLElementEventMap {
+    'item-selected': CustomEvent<ItemSelectedEvent>;
+    'bind-value-changed': CustomEvent<ValueChangeEvent>;
+  }
+}
+
 @customElement('gr-textarea')
 export class GrTextarea extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
@@ -85,8 +94,8 @@
   /**
    * @event bind-value-changed
    */
-  @property({type: Boolean})
-  autocomplete?: boolean;
+  @property({type: String})
+  autocomplete?: string;
 
   @property({type: Boolean})
   disabled?: boolean;
@@ -101,7 +110,7 @@
   placeholder?: string;
 
   @property({type: String, notify: true, observer: '_handleTextChanged'})
-  text?: string;
+  text = '';
 
   @property({type: Boolean})
   hideBorder = false;
@@ -125,10 +134,10 @@
   _hideEmojiAutocomplete = true;
 
   @property({type: Number})
-  _index?: number;
+  _index: number | null = null;
 
   @property({type: Array})
-  _suggestions?: EmojiSuggestion[];
+  _suggestions: EmojiSuggestion[] = [];
 
   @property({type: Number})
   readonly _verticalOffset = 20;
@@ -227,11 +236,14 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEnterByKey(e: CustomEvent<{keyboardEvent: KeyboardEvent}>) {
+  _handleEnterByKey(e: CustomKeyboardEvent) {
     // Enter should have newline behavior if the picker is closed or if the user
     // has only typed ':'. Also make sure that shortcuts aren't clobbered.
     if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-      if (!e.detail.keyboardEvent.metaKey && !e.detail.keyboardEvent.ctrlKey) {
+      if (
+        !e.detail.keyboardEvent?.metaKey &&
+        !e.detail.keyboardEvent?.ctrlKey
+      ) {
         this.indent(e);
       }
       return;
@@ -242,8 +254,10 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEmojiSelect(e: CustomEvent) {
-    this._setEmoji(e.detail.selected.dataset['value']);
+  _handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+    if (e.detail.selected?.dataset['value']) {
+      this._setEmoji(e.detail.selected?.dataset['value']);
+    }
   }
 
   _setEmoji(text: string) {
@@ -369,7 +383,7 @@
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
       suggestion.dataValue = suggestion.value;
-      suggestion.text = suggestion.value + ' ' + suggestion.match;
+      suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
     }
     this.set('_suggestions', suggestions);
@@ -404,7 +418,7 @@
     );
   }
 
-  private indent(e: CustomEvent<{keyboardEvent: KeyboardEvent}>): void {
+  private indent(e: CustomKeyboardEvent): void {
     if (!document.queryCommandSupported('insertText')) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
deleted file mode 100644
index 7c2f209..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
+++ /dev/null
@@ -1,373 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-textarea.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromElement('gr-textarea');
-
-const monospaceFixture = fixtureFromTemplate(html`
-<gr-textarea monospace="true"></gr-textarea>
-`);
-
-const hideBorderFixture = fixtureFromTemplate(html`
-<gr-textarea hide-border="true"></gr-textarea>
-`);
-
-suite('gr-textarea tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    sinon.stub(element.reporting, 'reportInteraction');
-  });
-
-  test('monospace is set properly', () => {
-    assert.isFalse(element.classList.contains('monospace'));
-  });
-
-  test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-  });
-
-  test('emoji selector is not open with the textarea lacks focus', () => {
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
-    element.text = ':';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector is not open when a general text is entered', () => {
-    MockInteractions.focus(element.$.textarea);
-    element.$.textarea.selectionStart = 9;
-    element.$.textarea.selectionEnd = 9;
-    element.text = 'some text';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector opens when a colon is typed & the textarea has focus',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideEmojiAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector opens when a colon is typed after space',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ' :';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 1);
-        assert.isFalse(element._hideEmojiAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector doesn\`t open when a colon is typed after character',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 5;
-        element.$.textarea.selectionEnd = 5;
-        element.text = 'test:';
-        flush();
-        assert.isTrue(element.$.emojiSuggestions.isHidden);
-        assert.isTrue(element._hideEmojiAutocomplete);
-      });
-
-  test('emoji selector opens when a colon is typed and some substring',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ':t';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideEmojiAutocomplete);
-        assert.equal(element._currentSearchString, 't');
-      });
-
-  test('emoji selector opens when a colon is typed in middle of text',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        // Since selectionStart is on Chrome set always on end of text, we
-        // stub it to 1
-        const text = ': hello';
-        sinon.stub(element.$, 'textarea').value( {
-          selectionStart: 1,
-          value: text,
-          textarea: {
-            focus: () => {},
-          },
-        });
-        element.text = text;
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideEmojiAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-  test('emoji selector closes when text changes before the colon', () => {
-    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
-    MockInteractions.focus(element.$.textarea);
-    flush();
-    element.$.textarea.selectionStart = 10;
-    element.$.textarea.selectionEnd = 10;
-    element.text = 'test test ';
-    element.$.textarea.selectionStart = 12;
-    element.$.textarea.selectionEnd = 12;
-    element.text = 'test test :';
-    element.$.textarea.selectionStart = 15;
-    element.$.textarea.selectionEnd = 15;
-    element.text = 'test test :smi';
-
-    assert.equal(element._currentSearchString, 'smi');
-    assert.isFalse(resetStub.called);
-    element.text = 'test test test :smi';
-    assert.isTrue(resetStub.called);
-  });
-
-  test('_resetEmojiDropdown', () => {
-    const closeSpy = sinon.spy(element, 'closeDropdown');
-    element._resetEmojiDropdown();
-    assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideEmojiAutocomplete);
-    assert.equal(element._colonIndex, null);
-
-    element.$.emojiSuggestions.open();
-    flush();
-    element._resetEmojiDropdown();
-    assert.isTrue(closeSpy.called);
-  });
-
-  test('_determineSuggestions', () => {
-    const emojiText = 'tear';
-    const formatSpy = sinon.spy(element, '_formatSuggestions');
-    element._determineSuggestions(emojiText);
-    assert.isTrue(formatSpy.called);
-    assert.isTrue(formatSpy.lastCall.calledWithExactly(
-        [{dataValue: '😂', value: '😂', match: 'tears :\')',
-          text: '😂 tears :\')'},
-        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-        ]));
-  });
-
-  test('_formatSuggestions', () => {
-    const matchedSuggestions = [{value: '😢', match: 'tear'},
-      {value: '😂', match: 'tears'}];
-    element._formatSuggestions(matchedSuggestions);
-    assert.deepEqual(
-        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
-        element._suggestions);
-  });
-
-  test('_handleEmojiSelect', () => {
-    element.$.textarea.selectionStart = 16;
-    element.$.textarea.selectionEnd = 16;
-    element.text = 'test test :tears';
-    element._colonIndex = 10;
-    const selectedItem = {dataset: {value: '😂'}};
-    const event = {detail: {selected: selectedItem}};
-    element._handleEmojiSelect(event);
-    assert.equal(element.text, 'test test 😂');
-  });
-
-  test('_updateCaratPosition', () => {
-    element.$.textarea.selectionStart = 4;
-    element.$.textarea.selectionEnd = 4;
-    element.text = 'test';
-    element._updateCaratPosition();
-    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-        element.$.caratSpan.outerHTML);
-  });
-
-  test('newline receives matching indentation', async () => {
-    const indentCommand = sinon.stub(document, 'execCommand');
-    element.$.textarea.value = '    a';
-    element._handleEnterByKey(
-        new CustomEvent('keydown', {detail: {keyboardEvent: {keyCode: 13}}})
-    );
-    await flush();
-    assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
-  });
-
-  test('ctrl+enter and meta+enter do not indent', async () => {
-    const indentCommand = sinon.stub(document, 'execCommand');
-    element.$.textarea.value = '    a';
-    element._handleEnterByKey(
-        new CustomEvent('keydown', {
-          detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
-        })
-    );
-    await flush();
-    assert.isTrue(indentCommand.notCalled);
-
-    element._handleEnterByKey(
-        new CustomEvent('keydown', {
-          detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
-        })
-    );
-    await flush();
-    assert.isTrue(indentCommand.notCalled);
-  });
-
-  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-    element.$.emojiSuggestions.dispatchEvent(
-        new CustomEvent('dropdown-closed', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(resetSpy.called);
-  });
-
-  test('_onValueChanged fires bind-value-changed', () => {
-    const listenerStub = sinon.stub();
-    const eventObject = {currentTarget: {focused: false}};
-    element.addEventListener('bind-value-changed', listenerStub);
-    element._onValueChanged(eventObject);
-    assert.isTrue(listenerStub.called);
-  });
-
-  suite('keyboard shortcuts', () => {
-    function setupDropdown(callback) {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
-      element.text = ':1';
-      flush();
-    }
-
-    test('escape key', () => {
-      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isFalse(resetSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('up key', () => {
-      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isFalse(upSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isTrue(upSpy.called);
-    });
-
-    test('down key', () => {
-      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isFalse(downSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isTrue(downSpy.called);
-    });
-
-    test('enter key', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isTrue(enterSpy.called);
-      flush();
-      assert.equal(element.text, '💯');
-    });
-
-    test('enter key - ignored on just colon without more information', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-    });
-  });
-
-  suite('gr-textarea monospace', () => {
-  // gr-textarea set monospace class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-
-    setup(() => {
-      element = monospaceFixture.instantiate();
-    });
-
-    test('monospace is set properly', () => {
-      assert.isTrue(element.classList.contains('monospace'));
-    });
-  });
-
-  suite('gr-textarea hideBorder', () => {
-  // gr-textarea set noBorder class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-
-    setup(() => {
-      element = hideBorderFixture.instantiate();
-    });
-
-    test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
new file mode 100644
index 0000000..506c348
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -0,0 +1,390 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-textarea';
+import {GrTextarea} from './gr-textarea';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+
+const basicFixture = fixtureFromElement('gr-textarea');
+
+const monospaceFixture = fixtureFromTemplate(html`
+  <gr-textarea monospace="true"></gr-textarea>
+`);
+
+const hideBorderFixture = fixtureFromTemplate(html`
+  <gr-textarea hide-border="true"></gr-textarea>
+`);
+
+suite('gr-textarea tests', () => {
+  let element: GrTextarea;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.reporting, 'reportInteraction');
+  });
+
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
+
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
+
+  test('emoji selector is not open with the textarea lacks focus', () => {
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector is not open when a general text is entered', () => {
+    MockInteractions.focus(element.$.textarea);
+    element.$.textarea.selectionStart = 9;
+    element.$.textarea.selectionEnd = 9;
+    element.text = 'some text';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector opens when a colon is typed & the textarea has focus', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+
+  test('emoji selector opens when a colon is typed after space', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 2;
+    element.$.textarea.selectionEnd = 2;
+    element.text = ' :';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 1);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+
+  test('emoji selector doesn`t open when a colon is typed after character', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 5;
+    element.$.textarea.selectionEnd = 5;
+    element.text = 'test:';
+    flush();
+    assert.isTrue(element.$.emojiSuggestions.isHidden);
+    assert.isTrue(element._hideEmojiAutocomplete);
+  });
+
+  test('emoji selector opens when a colon is typed and some substring', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    element.$.textarea.selectionStart = 2;
+    element.$.textarea.selectionEnd = 2;
+    element.text = ':t';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, 't');
+  });
+
+  test('emoji selector opens when a colon is typed in middle of text', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    // Since selectionStart is on Chrome set always on end of text, we
+    // stub it to 1
+    const text = ': hello';
+    sinon.stub(element.$, 'textarea').value({
+      selectionStart: 1,
+      value: text,
+      textarea: {
+        focus: () => {},
+      },
+    });
+    element.text = text;
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+  test('emoji selector closes when text changes before the colon', () => {
+    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flush();
+    element.$.textarea.selectionStart = 10;
+    element.$.textarea.selectionEnd = 10;
+    element.text = 'test test ';
+    element.$.textarea.selectionStart = 12;
+    element.$.textarea.selectionEnd = 12;
+    element.text = 'test test :';
+    element.$.textarea.selectionStart = 15;
+    element.$.textarea.selectionEnd = 15;
+    element.text = 'test test :smi';
+
+    assert.equal(element._currentSearchString, 'smi');
+    assert.isFalse(resetStub.called);
+    element.text = 'test test test :smi';
+    assert.isTrue(resetStub.called);
+  });
+
+  test('_resetEmojiDropdown', () => {
+    const closeSpy = sinon.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideEmojiAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flush();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sinon.spy(element, '_formatSuggestions');
+    element._determineSuggestions(emojiText);
+    assert.isTrue(formatSpy.called);
+    assert.isTrue(
+      formatSpy.lastCall.calledWithExactly([
+        {
+          dataValue: '😂',
+          value: '😂',
+          match: "tears :')",
+          text: "😂 tears :')",
+        },
+        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+      ])
+    );
+  });
+
+  test('_formatSuggestions', () => {
+    const matchedSuggestions = [
+      {value: '😢', match: 'tear'},
+      {value: '😂', match: 'tears'},
+    ];
+    element._formatSuggestions(matchedSuggestions);
+    assert.deepEqual(
+      [
+        {value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+        {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
+      ],
+      element._suggestions
+    );
+  });
+
+  test('_handleEmojiSelect', () => {
+    element.$.textarea.selectionStart = 16;
+    element.$.textarea.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element._colonIndex = 10;
+    const selectedItem = ({dataset: {value: '😂'}} as unknown) as HTMLElement;
+    const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
+      detail: {trigger: 'click', selected: selectedItem},
+    });
+    element._handleEmojiSelect(event);
+    assert.equal(element.text, 'test test 😂');
+  });
+
+  test('_updateCaratPosition', () => {
+    element.$.textarea.selectionStart = 4;
+    element.$.textarea.selectionEnd = 4;
+    element.text = 'test';
+    element._updateCaratPosition();
+    assert.deepEqual(
+      element.$.hiddenText.innerHTML,
+      element.text + element.$.caratSpan.outerHTML
+    );
+  });
+
+  test('newline receives matching indentation', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+      new CustomEvent('keydown', {
+        detail: {keyboardEvent: {keyCode: 13}},
+      }) as CustomKeyboardEvent
+    );
+    await flush();
+    assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
+  });
+
+  test('ctrl+enter and meta+enter do not indent', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+      new CustomEvent('keydown', {
+        detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
+      }) as CustomKeyboardEvent
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+
+    element._handleEnterByKey(
+      new CustomEvent('keydown', {
+        detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
+      }) as CustomKeyboardEvent
+    );
+    await flush();
+    assert.isTrue(indentCommand.notCalled);
+  });
+
+  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+    element.$.emojiSuggestions.dispatchEvent(
+      new CustomEvent('dropdown-closed', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(resetSpy.called);
+  });
+
+  test('_onValueChanged fires bind-value-changed', () => {
+    const listenerStub = sinon.stub();
+    const eventObject = new CustomEvent('bind-value-changed', {
+      detail: {currentTarget: {focused: false}, value: ''},
+    });
+    element.addEventListener('bind-value-changed', listenerStub);
+    element._onValueChanged(eventObject);
+    assert.isTrue(listenerStub.called);
+  });
+
+  suite('keyboard shortcuts', () => {
+    function setupDropdown() {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
+      flush();
+    }
+
+    test('escape key', () => {
+      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isFalse(resetSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isTrue(resetSpy.called);
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('up key', () => {
+      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isFalse(upSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isTrue(upSpy.called);
+    });
+
+    test('down key', () => {
+      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isFalse(downSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isTrue(downSpy.called);
+    });
+
+    test('enter key', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isTrue(enterSpy.called);
+      flush();
+      assert.equal(element.text, '💯');
+    });
+
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      flush();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+    });
+  });
+
+  suite('gr-textarea monospace', () => {
+    // gr-textarea set monospace class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element: GrTextarea;
+
+    setup(() => {
+      element = monospaceFixture.instantiate() as GrTextarea;
+    });
+
+    test('monospace is set properly', () => {
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  suite('gr-textarea hideBorder', () => {
+    // gr-textarea set noBorder class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element: GrTextarea;
+
+    setup(() => {
+      element = hideBorderFixture.instantiate() as GrTextarea;
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index b26ec9b..bbc7b1a 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -111,7 +111,7 @@
   if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
   else drafts[draft.path] = [...drafts[draft.path]];
   const index = drafts[draft.path].findIndex(
-    d => d.__draftID === draft.__draftID || d.id === draft.id
+    d => (d.__draftID && d.__draftID === draft.__draftID) || d.id === draft.id
   );
   if (index !== -1) {
     drafts[draft.path][index] = draft;
@@ -127,7 +127,7 @@
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
   const index = (drafts[draft.path] || []).findIndex(
-    d => d.__draftID === draft.__draftID || d.id === draft.id
+    d => (d.__draftID && d.__draftID === draft.__draftID) || d.id === draft.id
   );
   if (index === -1) return;
   drafts[draft.path] = [...drafts[draft.path]];
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 134003b..1996800 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -128,20 +128,20 @@
 
     --error-foreground: var(--red-700);
     --error-background: var(--red-50);
-    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04), var(--red-50));
-    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12), var(--red-50));
+    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04)), var(--red-50);
+    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12)), var(--red-50);
     --error-ripple: var(--red-700-10);
 
     --warning-foreground: var(--orange-700);
     --warning-background: var(--orange-50);
-    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04), var(--orange-50));
-    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12), var(--orange-50));
+    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04)), var(--orange-50);
+    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12)), var(--orange-50);
     --warning-ripple: var(--orange-700-10);
 
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
-    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04), var(--blue-50));
-    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12), var(--blue-50));
+    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04)), var(--blue-50);
+    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12)), var(--blue-50);
     --info-ripple: var(--blue-700-10);
 
     --primary-button-text-color: white;
@@ -154,14 +154,14 @@
 
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
-    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04), var(--green-50));
-    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12), var(--green-50));
+    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04)), var(--green-50);
+    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12)), var(--green-50);
     --success-ripple: var(--green-700-10);
 
     --gray-foreground: var(--gray-700);
     --gray-background: var(--gray-100);
-    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04), var(--gray-100));
-    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12), var(--gray-100));
+    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04)), var(--gray-100);
+    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12)), var(--gray-100);
     --gray-ripple: var(--gray-700-10);
 
     --disabled-foreground: var(--gray-800-38);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 69256b2..926b02d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -38,20 +38,20 @@
 
       --error-foreground: var(--red-200);
       --error-background: var(--red-tonal);
-      --error-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--red-tonal));
-      --error-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--red-tonal));
+      --error-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--red-tonal);
+      --error-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--red-tonal);
       --error-ripple: var(--white-10);
 
       --warning-foreground: var(--orange-200);
       --warning-background: var(--orange-tonal);
-      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--orange-tonal));
-      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--orange-tonal));
+      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--orange-tonal);
+      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--orange-tonal);
       --warning-ripple: var(--white-10);
 
       --info-foreground: var(--blue-200);
       --info-background: var(--blue-tonal);
-      --info-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--blue-tonal));
-      --info-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--blue-tonal));
+      --info-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--blue-tonal);
+      --info-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--blue-tonal);
       --info-ripple: var(--white-10);
 
       --primary-button-text-color: black;
@@ -64,14 +64,14 @@
 
       --success-foreground: var(--green-200);
       --success-background: var(--green-tonal);
-      --success-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--green-tonal));
-      --success-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--green-tonal));
+      --success-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--green-tonal);
+      --success-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--green-tonal);
       --success-ripple: var(--white-10);
 
       --gray-foreground: var(--gray-300);
       --gray-background: var(--gray-tonal);
-      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--gray-tonal));
-      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--gray-tonal));
+      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--gray-tonal);
+      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--gray-tonal);
       --gray-ripple: var(--white-10);
 
       --disabled-foreground: var(--gray-200-38);
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'
+    );
   });
 });
diff --git a/proto/entities.proto b/proto/entities.proto
index 84c7fbd..d4ff736 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -35,7 +35,6 @@
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
-  optional int32 row_version = 3;
   optional fixed64 created_on = 4;
   optional fixed64 last_updated_on = 5;
   optional Account_Id owner_account_id = 7;
@@ -54,6 +53,7 @@
   optional PatchSet_Id cherry_pick_of = 24;
 
   // Deleted fields, should not be reused:
+  reserved 3;    // row_version
   reserved 6;    // sortkey
   reserved 9;    // open
   reserved 11;   // nbrPatchSets