Merge "Introduce topic bulk action flow"
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 8e5463d..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -204,6 +204,13 @@
 
 * `-Dcom.google.gerrit.scenarios.context_path=/context`
 
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index 7946f05..5af4189 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -108,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index 5d5f5d5..5d8dd6f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -32,6 +32,9 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
     super.replaceOverride(next)
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..658409c 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -213,6 +213,7 @@
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(AddReviewersOp.Factory.class);
+    factory(PostReviewOp.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 39bd0d0..8b47e1e 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentValidationContext;
-import com.google.gerrit.extensions.validators.CommentValidationFailure;
-import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -121,26 +99,17 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -150,7 +119,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -186,21 +154,15 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +170,22 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +194,17 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +212,9 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
-    this.commentValidators = commentValidators;
-    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
-    this.publishPatchSetLevelComment =
-        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
@@ -432,8 +378,9 @@
 
       // Add the review op.
       logger.atFine().log("posting review");
-      bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+      bu.addOp(revision.getChange().getId(), postReviewOp);
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
@@ -488,7 +435,9 @@
       Change change,
       List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
-    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(
+            getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
       List<Account.Id> removed = new ArrayList<>();
@@ -699,10 +648,6 @@
         .collect(toList());
   }
 
-  private TraceContext.TraceTimer newTimer(String method) {
-    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
-  }
-
   private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
@@ -1009,645 +954,4 @@
     @Nullable
     abstract Comment.Range range();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private String mailMessage;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, UnprocessableEntityException, IOException,
-            CommentsRejectedException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getNotes(), psId);
-      List<RobotComment> newRobotComments =
-          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
-      boolean dirty = false;
-      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
-        dirty |= insertComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
-        dirty |= insertRobotComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
-        dirty |= updateLabels(projectState, ctx);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
-        dirty |= insertMessage(ctx);
-      }
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-      if (notify.shouldNotify()) {
-        try {
-          email
-              .create(
-                  notify,
-                  notes,
-                  ps,
-                  user,
-                  mailMessage,
-                  ctx.getWhen(),
-                  comments,
-                  in.message,
-                  labelDelta,
-                  ctx.getRepoView())
-              .sendAsync();
-        } catch (IOException ex) {
-          throw new StorageException(
-              String.format("Repository %s not found", ctx.getProject().get()), ex);
-        }
-      }
-      String comment = mailMessage;
-      if (publishPatchSetLevelComment) {
-        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
-        // added event. For backwards compatibility, patchset level comment has a higher priority
-        // than change message and should be used as comment in comment added event.
-        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
-          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
-          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
-            CommentInput firstComment = patchSetLevelComments.get(0);
-            if (!Strings.isNullOrEmpty(firstComment.message)) {
-              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
-            }
-          }
-        }
-      }
-      commentAdded.fire(
-          ctx.getChangeData(notes),
-          ps,
-          user.state(),
-          comment,
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    /**
-     * Publishes draft and input comments. Input comments are those passed as input in the request
-     * body.
-     *
-     * @param ctx context for performing the change update.
-     * @param newRobotComments robot comments. Used only for validation in this method.
-     * @return true if any input comments where published.
-     */
-    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws CommentsRejectedException {
-      Map<String, List<CommentInput>> inputComments = in.comments;
-      if (inputComments == null) {
-        inputComments = Collections.emptyMap();
-      }
-
-      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
-      Map<String, HumanComment> drafts = new HashMap<>();
-
-      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        drafts =
-            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
-                ? changeDrafts(ctx)
-                : patchSetDrafts(ctx);
-      }
-
-      // Existing published comments
-      Set<CommentSetEntry> existingComments =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      // Input comments should be deduplicated from existing drafts
-      List<HumanComment> inputCommentsToPublish =
-          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          Collection<HumanComment> filteredDrafts =
-              in.draftIdsToPublish == null
-                  ? drafts.values()
-                  : drafts.values().stream()
-                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
-                      .collect(Collectors.toList());
-
-          validateComments(
-              ctx,
-              Streams.concat(
-                  drafts.values().stream(),
-                  inputCommentsToPublish.stream(),
-                  newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      commentsUtil.putHumanComments(
-          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
-      comments.addAll(inputCommentsToPublish);
-      return !inputCommentsToPublish.isEmpty();
-    }
-
-    /**
-     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
-     * id) neither in {@code existingComments} nor in {@code drafts}.
-     *
-     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-     * removed.
-     *
-     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
-     * @param existingComments existing published comments in the database.
-     * @param drafts existing draft comments in the database. This map can be modified.
-     */
-    private List<HumanComment> resolveInputCommentsAndDrafts(
-        Map<String, List<CommentInput>> inputComments,
-        Set<CommentSetEntry> existingComments,
-        Map<String, HumanComment> drafts,
-        ChangeContext ctx) {
-      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
-      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
-        String path = entry.getKey();
-        for (CommentInput inputComment : entry.getValue()) {
-          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
-          if (comment == null) {
-            String parent = Url.decode(inputComment.inReplyTo);
-            comment =
-                commentsUtil.newHumanComment(
-                    ctx.getNotes(),
-                    ctx.getUser(),
-                    ctx.getWhen(),
-                    path,
-                    psId,
-                    inputComment.side(),
-                    inputComment.message,
-                    inputComment.unresolved,
-                    parent);
-          } else {
-            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = Timestamp.from(ctx.getWhen());
-            comment.side = inputComment.side();
-            comment.message = inputComment.message;
-          }
-
-          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
-          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
-          comment.tag = in.tag;
-
-          if (existingComments.contains(CommentSetEntry.create(comment))) {
-            continue;
-          }
-          inputCommentsToPublish.add(comment);
-        }
-      }
-      return inputCommentsToPublish;
-    }
-
-    /**
-     * Validates all comments and the change message in a single call to fulfill the interface
-     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
-     * ImmutableList)}.
-     */
-    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
-        throws CommentsRejectedException {
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(),
-              ctx.getChange().getProject().get(),
-              ctx.getChange().getDest().branch());
-      String changeMessage = Strings.nullToEmpty(in.message).trim();
-      ImmutableList<CommentForValidation> draftsForValidation =
-          Stream.concat(
-                  comments.map(
-                      comment ->
-                          CommentForValidation.create(
-                              comment instanceof RobotComment
-                                  ? CommentForValidation.CommentSource.ROBOT
-                                  : CommentForValidation.CommentSource.HUMAN,
-                              comment.lineNbr > 0
-                                  ? CommentForValidation.CommentType.INLINE_COMMENT
-                                  : CommentForValidation.CommentType.FILE_COMMENT,
-                              comment.message,
-                              comment.getApproximateSize())),
-                  Stream.of(
-                      CommentForValidation.create(
-                          CommentForValidation.CommentSource.HUMAN,
-                          CommentForValidation.CommentType.CHANGE_MESSAGE,
-                          changeMessage,
-                          changeMessage.length())))
-              .collect(toImmutableList());
-      ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(
-              commentValidationCtx, commentValidators, draftsForValidation);
-      if (!draftValidationFailures.isEmpty()) {
-        throw new CommentsRejectedException(draftValidationFailures);
-      }
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
-      if (in.robotComments == null) {
-        return false;
-      }
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return ImmutableList.of();
-      }
-
-      ImmutableList.Builder<FixSuggestion> fixSuggestions =
-          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions.build();
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
-      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.label(), psa.value());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) {
-      return approvalsUtil
-          .getReviewers(ctx.getNotes())
-          .byState(REVIEWER)
-          .contains(ctx.getAccountId());
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException {
-      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt =
-            labelTypes
-                .byLabel(name)
-                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.value() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-          // Only allow voting again if the vote is copied over from a past patch-set, or the
-          // values are different.
-        } else if (c != null
-            && (c.value() != ent.getValue()
-                || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
-          PatchSetApproval.Builder b =
-              c.toBuilder()
-                  .value(ent.getValue())
-                  .granted(ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag));
-          ctx.getUser().updateRealAccountId(b::realAccountId);
-          c = b.build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.value() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.value());
-        } else if (c == null) {
-          c =
-              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
-    private boolean isApprovalCopiedOver(
-        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
-      return !changeNotes.getApprovals().onlyNonCopied()
-          .get(changeNotes.getChange().currentPatchSetId()).stream()
-          .anyMatch(p -> p.equals(patchSetApproval));
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().isNew()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (!ctx.getChange().isMerged()) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        if (prev > psa.value()) {
-          reduced.add(psa);
-        }
-        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced.stream()
-                    .map(PatchSetApproval::label)
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
-        if (lt.isPresent()) {
-          current.put(lt.get().getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        // Message was already validated when validating comments, since validators need to see
-        // everything in a single call.
-        buf.append("\n\n").append(msg);
-      } else if (in.ready) {
-        buf.append("\n\n" + START_REVIEW_MESSAGE);
-      }
-
-      List<String> pluginMessages = new ArrayList<>();
-      onPostReviews.runEach(
-          onPostReview ->
-              onPostReview
-                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
-                  .ifPresent(
-                      pluginMessage ->
-                          pluginMessages.add(
-                              !pluginMessage.endsWith("\n")
-                                  ? pluginMessage + "\n"
-                                  : pluginMessage)));
-      if (!pluginMessages.isEmpty()) {
-        buf.append("\n\n");
-        buf.append(Joiner.on("\n").join(pluginMessages));
-      }
-
-      if (buf.length() == 0) {
-        return false;
-      }
-
-      mailMessage =
-          cmUtil.setChangeMessage(
-              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..c3ab94c1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,774 @@
+// Copyright (C) 2022 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.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+  interface Factory {
+    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+  }
+
+  @VisibleForTesting
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PublishCommentUtil publishCommentUtil;
+  private final PatchSetUtil psUtil;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
+
+  private final ProjectState projectState;
+  private final PatchSet.Id psId;
+  private final ReviewInput in;
+  private final boolean publishPatchSetLevelComment;
+
+  private IdentifiedUser user;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private String mailMessage;
+  private List<Comment> comments = new ArrayList<>();
+  private List<LabelVote> labelDelta = new ArrayList<>();
+  private Map<String, Short> approvals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  PostReviewOp(
+      @GerritServerConfig Config gerritConfig,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PublishCommentUtil publishCommentUtil,
+      PatchSetUtil psUtil,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
+      @Assisted ProjectState projectState,
+      @Assisted PatchSet.Id psId,
+      @Assisted ReviewInput in) {
+    this.approvalsUtil = approvalsUtil;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+    this.projectState = projectState;
+    this.psId = psId;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getNotes(), psId);
+    List<RobotComment> newRobotComments =
+        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+    boolean dirty = false;
+    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+      dirty |= insertComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+      dirty |= insertRobotComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+      dirty |= updateLabels(projectState, ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+      dirty |= insertMessage(ctx);
+    }
+    return dirty;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+    if (notify.shouldNotify()) {
+      try {
+        email
+            .create(
+                notify,
+                notes,
+                ps,
+                user,
+                mailMessage,
+                ctx.getWhen(),
+                comments,
+                in.message,
+                labelDelta,
+                ctx.getRepoView())
+            .sendAsync();
+      } catch (IOException ex) {
+        throw new StorageException(
+            String.format("Repository %s not found", ctx.getProject().get()), ex);
+      }
+    }
+    String comment = mailMessage;
+    if (publishPatchSetLevelComment) {
+      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+      // added event. For backwards compatibility, patchset level comment has a higher priority
+      // than change message and should be used as comment in comment added event.
+      if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+        List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+        if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+          CommentInput firstComment = patchSetLevelComments.get(0);
+          if (!Strings.isNullOrEmpty(firstComment.message)) {
+            comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+          }
+        }
+      }
+    }
+    commentAdded.fire(
+        ctx.getChangeData(notes),
+        ps,
+        user.state(),
+        comment,
+        approvals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  /**
+   * Publishes draft and input comments. Input comments are those passed as input in the request
+   * body.
+   *
+   * @param ctx context for performing the change update.
+   * @param newRobotComments robot comments. Used only for validation in this method.
+   * @return true if any input comments where published.
+   */
+  private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+      throws CommentsRejectedException {
+    Map<String, List<CommentInput>> inputComments = in.comments;
+    if (inputComments == null) {
+      inputComments = Collections.emptyMap();
+    }
+
+    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+    Map<String, HumanComment> drafts = new HashMap<>();
+
+    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      drafts =
+          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+              ? changeDrafts(ctx)
+              : patchSetDrafts(ctx);
+    }
+
+    // Existing published comments
+    Set<CommentSetEntry> existingComments =
+        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+    // Input comments should be deduplicated from existing drafts
+    List<HumanComment> inputCommentsToPublish =
+        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+    switch (in.drafts) {
+      case PUBLISH:
+      case PUBLISH_ALL_REVISIONS:
+        Collection<HumanComment> filteredDrafts =
+            in.draftIdsToPublish == null
+                ? drafts.values()
+                : drafts.values().stream()
+                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                    .collect(Collectors.toList());
+
+        validateComments(
+            ctx,
+            Streams.concat(
+                drafts.values().stream(),
+                inputCommentsToPublish.stream(),
+                newRobotComments.stream()));
+        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+        comments.addAll(drafts.values());
+        break;
+      case KEEP:
+        validateComments(
+            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+        break;
+    }
+    commentsUtil.putHumanComments(
+        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+    comments.addAll(inputCommentsToPublish);
+    return !inputCommentsToPublish.isEmpty();
+  }
+
+  /**
+   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+   * neither in {@code existingComments} nor in {@code drafts}.
+   *
+   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+   * removed.
+   *
+   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+   * @param existingComments existing published comments in the database.
+   * @param drafts existing draft comments in the database. This map can be modified.
+   */
+  private List<HumanComment> resolveInputCommentsAndDrafts(
+      Map<String, List<CommentInput>> inputComments,
+      Set<CommentSetEntry> existingComments,
+      Map<String, HumanComment> drafts,
+      ChangeContext ctx) {
+    List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+    for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+      String path = entry.getKey();
+      for (CommentInput inputComment : entry.getValue()) {
+        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+        if (comment == null) {
+          String parent = Url.decode(inputComment.inReplyTo);
+          comment =
+              commentsUtil.newHumanComment(
+                  ctx.getNotes(),
+                  ctx.getUser(),
+                  ctx.getWhen(),
+                  path,
+                  psId,
+                  inputComment.side(),
+                  inputComment.message,
+                  inputComment.unresolved,
+                  parent);
+        } else {
+          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          comment.writtenOn = Timestamp.from(ctx.getWhen());
+          comment.side = inputComment.side();
+          comment.message = inputComment.message;
+        }
+
+        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+        comment.tag = in.tag;
+
+        if (existingComments.contains(CommentSetEntry.create(comment))) {
+          continue;
+        }
+        inputCommentsToPublish.add(comment);
+      }
+    }
+    return inputCommentsToPublish;
+  }
+
+  /**
+   * Validates all comments and the change message in a single call to fulfill the interface
+   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+   */
+  private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+      throws CommentsRejectedException {
+    CommentValidationContext commentValidationCtx =
+        CommentValidationContext.create(
+            ctx.getChange().getChangeId(),
+            ctx.getChange().getProject().get(),
+            ctx.getChange().getDest().branch());
+    String changeMessage = Strings.nullToEmpty(in.message).trim();
+    ImmutableList<CommentForValidation> draftsForValidation =
+        Stream.concat(
+                comments.map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment instanceof RobotComment
+                                ? CommentForValidation.CommentSource.ROBOT
+                                : CommentForValidation.CommentSource.HUMAN,
+                            comment.lineNbr > 0
+                                ? CommentForValidation.CommentType.INLINE_COMMENT
+                                : CommentForValidation.CommentType.FILE_COMMENT,
+                            comment.message,
+                            comment.getApproximateSize())),
+                Stream.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        changeMessage,
+                        changeMessage.length())))
+            .collect(toImmutableList());
+    ImmutableList<CommentValidationFailure> draftValidationFailures =
+        PublishCommentUtil.findInvalidComments(
+            commentValidationCtx, commentValidators, draftsForValidation);
+    if (!draftValidationFailures.isEmpty()) {
+      throw new CommentsRejectedException(draftValidationFailures);
+    }
+  }
+
+  private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+    if (in.robotComments == null) {
+      return false;
+    }
+    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+    comments.addAll(newRobotComments);
+    return !newRobotComments.isEmpty();
+  }
+
+  private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+    List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+    Set<CommentSetEntry> existingIds =
+        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+    for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+      String path = ent.getKey();
+      for (RobotCommentInput c : ent.getValue()) {
+        RobotComment e = createRobotCommentFromInput(ctx, path, c);
+        if (existingIds.contains(CommentSetEntry.create(e))) {
+          continue;
+        }
+        toAdd.add(e);
+      }
+    }
+    return toAdd;
+  }
+
+  private RobotComment createRobotCommentFromInput(
+      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+    RobotComment robotComment =
+        commentsUtil.newRobotComment(
+            ctx,
+            path,
+            psId,
+            robotCommentInput.side(),
+            robotCommentInput.message,
+            robotCommentInput.robotId,
+            robotCommentInput.robotRunId);
+    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+    robotComment.url = robotCommentInput.url;
+    robotComment.properties = robotCommentInput.properties;
+    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+    robotComment.tag = in.tag;
+    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    return robotComment;
+  }
+
+  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+  }
+
+  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+  }
+
+  private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+    Map<String, Short> labels = new HashMap<>();
+    for (PatchSetApproval psa : patchsetApprovals) {
+      labels.put(psa.label(), psa.value());
+    }
+    return labels;
+  }
+
+  private Map<String, Short> getAllApprovals(
+      LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+    Map<String, Short> allApprovals = new HashMap<>();
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      allApprovals.put(lt.getName(), (short) 0);
+    }
+    // set approvals to existing votes
+    if (current != null) {
+      allApprovals.putAll(current);
+    }
+    // set approvals to new votes
+    if (input != null) {
+      allApprovals.putAll(input);
+    }
+    return allApprovals;
+  }
+
+  private Map<String, Short> getPreviousApprovals(
+      Map<String, Short> allApprovals, Map<String, Short> current) {
+    Map<String, Short> previous = new HashMap<>();
+    for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+      // assume vote is 0 if there is no vote
+      if (!current.containsKey(approval.getKey())) {
+        previous.put(approval.getKey(), (short) 0);
+      } else {
+        previous.put(approval.getKey(), current.get(approval.getKey()));
+      }
+    }
+    return previous;
+  }
+
+  private boolean isReviewer(ChangeContext ctx) {
+    return approvalsUtil
+        .getReviewers(ctx.getNotes())
+        .byState(REVIEWER)
+        .contains(ctx.getAccountId());
+  }
+
+  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+      throws ResourceConflictException {
+    Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+    // If no labels were modified and change is closed, abort early.
+    // This avoids trying to record a modified label caused by a user
+    // losing access to a label after the change was submitted.
+    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+      return false;
+    }
+
+    List<PatchSetApproval> del = new ArrayList<>();
+    List<PatchSetApproval> ups = new ArrayList<>();
+    Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, Short> allApprovals =
+        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+    Map<String, Short> previous =
+        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+      String name = ent.getKey();
+      LabelType lt =
+          labelTypes
+              .byLabel(name)
+              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+      PatchSetApproval c = current.remove(lt.getName());
+      String normName = lt.getName();
+      approvals.put(normName, (short) 0);
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // User requested delete of this label.
+        oldApprovals.put(normName, null);
+        if (c != null) {
+          if (c.value() != 0) {
+            addLabelDelta(normName, (short) 0);
+            oldApprovals.put(normName, previous.get(normName));
+          }
+          del.add(c);
+          update.putApproval(normName, (short) 0);
+        }
+        // Only allow voting again if the vote is copied over from a past patch-set, or the
+        // values are different.
+      } else if (c != null
+          && (c.value() != ent.getValue()
+              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+        PatchSetApproval.Builder b =
+            c.toBuilder()
+                .value(ent.getValue())
+                .granted(ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag));
+        ctx.getUser().updateRealAccountId(b::realAccountId);
+        c = b.build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putApproval(normName, ent.getValue());
+      } else if (c != null && c.value() == ent.getValue()) {
+        current.put(normName, c);
+        oldApprovals.put(normName, null);
+        approvals.put(normName, c.value());
+      } else if (c == null) {
+        c =
+            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag))
+                .granted(ctx.getWhen())
+                .build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putReviewer(user.getAccountId(), REVIEWER);
+        update.putApproval(normName, ent.getValue());
+      }
+    }
+
+    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+    // Return early if user is not a reviewer and not posting any labels.
+    // This allows us to preserve their CC status.
+    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+      return false;
+    }
+
+    return !del.isEmpty() || !ups.isEmpty();
+  }
+
+  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+    return !changeNotes.getApprovals().onlyNonCopied()
+        .get(changeNotes.getChange().currentPatchSetId()).stream()
+        .anyMatch(p -> p.equals(patchSetApproval));
+  }
+
+  private void validatePostSubmitLabels(
+      ChangeContext ctx,
+      LabelTypes labelTypes,
+      Map<String, Short> previous,
+      List<PatchSetApproval> ups,
+      List<PatchSetApproval> del)
+      throws ResourceConflictException {
+    if (ctx.getChange().isNew()) {
+      return; // Not closed, nothing to validate.
+    } else if (del.isEmpty() && ups.isEmpty()) {
+      return; // No new votes.
+    } else if (!ctx.getChange().isMerged()) {
+      throw new ResourceConflictException("change is closed");
+    }
+
+    // Disallow reducing votes on any labels post-submit. This assumes the
+    // high values were broadly necessary to submit, so reducing them would
+    // make it possible to take a merged change and make it no longer
+    // submittable.
+    List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+    List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+    for (PatchSetApproval psa : del) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev != null && prev != 0) {
+        reduced.add(psa);
+      }
+    }
+
+    for (PatchSetApproval psa : ups) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev == null) {
+        continue;
+      }
+      if (prev > psa.value()) {
+        reduced.add(psa);
+      }
+      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+    }
+
+    if (!disallowed.isEmpty()) {
+      throw new ResourceConflictException(
+          "Voting on labels disallowed after submit: "
+              + disallowed.stream().distinct().sorted().collect(joining(", ")));
+    }
+    if (!reduced.isEmpty()) {
+      throw new ResourceConflictException(
+          "Cannot reduce vote on labels for closed change: "
+              + reduced.stream()
+                  .map(PatchSetApproval::label)
+                  .distinct()
+                  .sorted()
+                  .collect(joining(", ")));
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(
+      ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, PatchSetApproval> current = new HashMap<>();
+
+    for (PatchSetApproval a :
+        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+      if (a.isLegacySubmit()) {
+        continue;
+      }
+
+      Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+      if (lt.isPresent()) {
+        current.put(lt.get().getName(), a);
+      } else {
+        del.add(a);
+      }
+    }
+    return current;
+  }
+
+  private boolean insertMessage(ChangeContext ctx) {
+    String msg = Strings.nullToEmpty(in.message).trim();
+
+    StringBuilder buf = new StringBuilder();
+    for (LabelVote d : labelDelta) {
+      buf.append(" ").append(d.format());
+    }
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!msg.isEmpty()) {
+      // Message was already validated when validating comments, since validators need to see
+      // everything in a single call.
+      buf.append("\n\n").append(msg);
+    } else if (in.ready) {
+      buf.append("\n\n" + START_REVIEW_MESSAGE);
+    }
+
+    List<String> pluginMessages = new ArrayList<>();
+    onPostReviews.runEach(
+        onPostReview ->
+            onPostReview
+                .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                .ifPresent(
+                    pluginMessage ->
+                        pluginMessages.add(
+                            !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
+    if (!pluginMessages.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(Joiner.on("\n").join(pluginMessages));
+    }
+
+    if (buf.length() == 0) {
+      return false;
+    }
+
+    mailMessage =
+        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+    return true;
+  }
+
+  private void addLabelDelta(String name, short value) {
+    labelDelta.add(LabelVote.create(name, value));
+  }
+
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6013862..4704a89 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -56,7 +56,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -952,13 +952,13 @@
     // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
     StagedChange sc = stageWipChange();
     ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+        ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
     Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+    Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
   }
 
   private void review(TestAccount account, String changeId, EmailStrategy strategy)
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 08e2e66..6788aa3 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -334,6 +334,10 @@
   lineNum: LineNumber;
 }
 
+export declare interface RenderProgressEventDetail {
+  linesRendered: number;
+}
+
 export declare interface DisplayLine {
   side: Side;
   lineNum: LineNumber;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 119b905..a4ef0c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -65,7 +65,8 @@
   // private but used in test
   @state() topic?: string;
 
-  @state() private baseChange?: ChangeId;
+  @property({type: String})
+  baseChange?: ChangeId;
 
   @state() private baseCommit?: string;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 6d0db71..7b14612 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -11,7 +11,6 @@
 import {pluralize} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-button/gr-button';
-import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
 import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
 import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
 import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
@@ -113,8 +112,6 @@
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
-            <gr-change-list-bulk-abandon-flow>
-            </gr-change-list-bulk-abandon-flow>
           </div>
         </div>
       </td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index d267b22..bc73990 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -67,8 +67,6 @@
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
-            <gr-change-list-bulk-abandon-flow>
-            </gr-change-list-bulk-abandon-flow>
           </div>
         </div>
       </td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index a2d8ea5..2701e91 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -42,6 +42,8 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly reportingService = getAppContext().reportingService;
+
   @state() selectedChanges: ChangeInfo[] = [];
 
   @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
@@ -195,8 +197,12 @@
     fireReload(this, true);
   }
 
-  private handleConfirm() {
+  private async handleConfirm() {
     this.progressByChange.clear();
+    this.reportingService.reportInteraction('bulk-action', {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     const reviewInput: ReviewInput = {
       labels: this.getLabelValues(
         this.computeCommonPermittedLabels(this.computePermittedLabels())
@@ -207,6 +213,7 @@
     }
     this.requestUpdate();
     const promises = this.getBulkActionsModel().voteChanges(reviewInput);
+
     for (let index = 0; index < promises.length; index++) {
       const changeNum = this.selectedChanges[index]._number;
       promises[index]
@@ -227,6 +234,34 @@
           }
         });
     }
+
+    // TODO: replace with Promise.allSettled once we upgrade to ES2020 or higher
+    // The names and types here match Promise.allSettled.
+    await Promise.all(
+      promises.map(promise =>
+        promise
+          .then(value => {
+            return {
+              status: 'fulfilled',
+              value,
+            };
+          })
+          .catch(reason => {
+            return {
+              status: 'rejected',
+              reason,
+            };
+          })
+      )
+    );
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'vote',
+        count: Array.from(this.progressByChange.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    }
   }
 
   // private but used in tests
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 1dd737d..ab2b4b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -18,6 +18,7 @@
   query,
   mockPromise,
   queryAll,
+  stubReporting,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
@@ -34,6 +35,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {ProgressStatus} from '../../../constants/constants';
 import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const change1: ChangeInfo = {
   ...createChange(),
@@ -96,6 +98,7 @@
   let getChangesStub: SinonStubbedMember<
     RestApiService['getDetailedChangesWithActions']
   >;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -108,6 +111,7 @@
   setup(async () => {
     model = new BulkActionsModel(getAppContext().restApiService);
     getChangesStub = stubRestApi('getDetailedChangesWithActions');
+    reportingStub = stubReporting('reportInteraction');
     element = (
       await fixture(
         wrapInProvider(
@@ -256,6 +260,11 @@
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
     );
 
+    assert.deepEqual(reportingStub.lastCall.args[1], {
+      type: 'vote',
+      selectedChangeCount: 1,
+    });
+
     assert.equal(
       element.progressByChange.get(1 as NumericChangeId),
       ProgressStatus.RUNNING
@@ -321,6 +330,14 @@
       // Dialog does not autoclose and fire reload event if some request fails
       assert.isFalse(dispatchEventStub.called);
 
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'vote',
+          count: 2,
+        },
+      ]);
+
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
       await waitUntil(() => dispatchEventStub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index bb7b6ec..8b12789 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -22,6 +22,7 @@
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
 
 const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
   ReviewerState,
@@ -40,7 +41,10 @@
   @state() private updatedAccountsByReviewerState: Map<
     ReviewerState,
     AccountInfo[]
-  > = new Map();
+  > = new Map([
+    [ReviewerState.REVIEWER, []],
+    [ReviewerState.CC, []],
+  ]);
 
   @state() private suggestionsProviderByReviewerState: Map<
     ReviewerState,
@@ -56,6 +60,8 @@
 
   @query('gr-overlay') private overlay!: GrOverlay;
 
+  private readonly reportingService = getAppContext().reportingService;
+
   private getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
   private restApiService = getAppContext().restApiService;
@@ -147,6 +153,8 @@
         .removableValues=${[]}
         .suggestionsProvider=${suggestionsProvider}
         .placeholder=${placeholder}
+        @account-added=${(e: CustomEvent<AccountInputDetail>) =>
+          this.onAccountAdded(reviewerState, e)}
       >
       </gr-account-list>
     `;
@@ -185,6 +193,28 @@
     this.requestUpdate();
   }
 
+  /* Removes accounts from one list when they are added to the other */
+  private onAccountAdded(
+    reviewerState: ReviewerState,
+    event: CustomEvent<AccountInputDetail>
+  ) {
+    const account = event.detail.account as AccountInfo;
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+    const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
+      acc => acc._account_id === account._account_id
+    );
+    if (oppositeUpdatedAccountIndex >= 0) {
+      oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
+      this.requestUpdate();
+    }
+  }
+
   private onConfirm(overallStatus: ProgressStatus) {
     switch (overallStatus) {
       case ProgressStatus.NOT_STARTED:
@@ -199,7 +229,11 @@
     }
   }
 
-  private saveReviewers() {
+  private async saveReviewers() {
+    this.reportingService.reportInteraction('bulk-action', {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     this.progressByChangeNum = new Map(
       this.selectedChanges.map(change => [
         change._number,
@@ -224,6 +258,34 @@
           this.requestUpdate();
         });
     }
+
+    // TODO: replace with Promise.allSettled once we upgrade to ES2020 or higher
+    // The names and types here match Promise.allSettled.
+    await Promise.all(
+      inFlightActions.map(promise =>
+        promise
+          .then(value => {
+            return {
+              status: 'fulfilled',
+              value,
+            };
+          })
+          .catch(reason => {
+            return {
+              status: 'rejected',
+              reason,
+            };
+          })
+      )
+    );
+    if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'add-reviewer',
+        count: Array.from(this.progressByChangeNum.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    }
   }
 
   private isFlowDisabled() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index afc7b4b..4cd76f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {fixture, html} from '@open-wc/testing-helpers';
+import {SinonStubbedMember} from 'sinon';
 import {AccountInfo, ReviewerState} from '../../../api/rest-api';
 import {
   BulkActionsModel,
@@ -11,6 +12,7 @@
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import '../../../test/common-test-setup-karma';
 import {
   createAccountWithIdNameAndEmail,
@@ -20,10 +22,12 @@
   MockPromise,
   mockPromise,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -60,6 +64,7 @@
 suite('gr-change-list-reviewer-flow tests', () => {
   let element: GrChangeListReviewerFlow;
   let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -71,6 +76,7 @@
 
   setup(async () => {
     stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
     model = new BulkActionsModel(getAppContext().restApiService);
     model.sync(changes);
 
@@ -223,6 +229,11 @@
       dialog.confirmButton!.click();
       await element.updateComplete;
 
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-reviewer',
+        selectedChangeCount: 2,
+      });
+
       assert.isTrue(saveChangeReviewStub.calledTwice);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
         changes[0]._number,
@@ -246,6 +257,58 @@
       ]);
     });
 
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
+
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      assert.isEmpty(reviewerList.accounts);
+    });
+
+    test('removes from cc list when added to reviewer', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[3],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      assert.isEmpty(ccList.accounts);
+    });
+
     test('confirm button text updates', async () => {
       assert.equal(dialog.confirmLabel, 'Add');
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 5f6732e..2946b3a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -29,7 +29,6 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-change-actions_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
@@ -47,7 +46,6 @@
   NotifyType,
 } from '../../../constants/constants';
 import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountInfo,
   ActionInfo,
@@ -63,11 +61,9 @@
   LabelInfo,
   NumericChangeId,
   PatchSetNum,
-  PropertyType,
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
-  ServerInfo,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -86,13 +82,17 @@
   ConfirmRebaseEventDetail,
   GrConfirmRebaseDialog,
 } from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {
+  fire,
+  fireAlert,
+  fireEvent,
+  fireReload,
+} from '../../../utils/event-util';
 import {
   getApprovalInfo,
   getVotingRange,
@@ -108,8 +108,13 @@
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
+import {assertIsDefined, queryAll} from '../../../utils/common-util';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -320,35 +325,11 @@
   init?(): void;
 }
 
-export interface GrChangeActions {
-  $: {
-    mainContent: Element;
-    overlay: GrOverlay;
-    confirmRebase: GrConfirmRebaseDialog;
-    confirmCherrypick: GrConfirmCherrypickDialog;
-    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
-    confirmMove: GrConfirmMoveDialog;
-    confirmRevertDialog: GrConfirmRevertDialog;
-    confirmAbandonDialog: GrConfirmAbandonDialog;
-    confirmSubmitDialog: GrConfirmSubmitDialog;
-    createFollowUpDialog: GrDialog;
-    createFollowUpChange: GrCreateChangeDialog;
-    confirmDeleteDialog: GrDialog;
-    confirmDeleteEditDialog: GrDialog;
-    moreActions: GrDropdown;
-    secondaryActions: HTMLElement;
-  };
-}
-
 @customElement('gr-change-actions')
 export class GrChangeActions
-  extends DIPolymerElement
+  extends LitElement
   implements GrChangeActionsElement
 {
-  static get template() {
-    return htmlTemplate;
-  }
-
   /**
    * Fired when the change should be reloaded.
    *
@@ -373,6 +354,37 @@
    * @event show-error
    */
 
+  @query('#mainContent') mainContent?: Element;
+
+  @query('#overlay') overlay?: GrOverlay;
+
+  @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
+
+  @query('#confirmCherrypick') confirmCherrypick?: GrConfirmCherrypickDialog;
+
+  @query('#confirmCherrypickConflict')
+  confirmCherrypickConflict?: GrConfirmCherrypickConflictDialog;
+
+  @query('#confirmMove') confirmMove?: GrConfirmMoveDialog;
+
+  @query('#confirmRevertDialog') confirmRevertDialog?: GrConfirmRevertDialog;
+
+  @query('#confirmAbandonDialog') confirmAbandonDialog?: GrConfirmAbandonDialog;
+
+  @query('#confirmSubmitDialog') confirmSubmitDialog?: GrConfirmSubmitDialog;
+
+  @query('#createFollowUpDialog') createFollowUpDialog?: GrDialog;
+
+  @query('#createFollowUpChange') createFollowUpChange?: GrCreateChangeDialog;
+
+  @query('#confirmDeleteDialog') confirmDeleteDialog?: GrDialog;
+
+  @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
+
+  @query('#moreActions') moreActions?: GrDropdown;
+
+  @query('#secondaryActions') secondaryActions?: HTMLElement;
+
   // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
   // properties are replaced with enums everywhere and remove them from
   // the GrChangeActions class
@@ -407,8 +419,8 @@
   @property({type: Boolean})
   _hasKnownChainState = false;
 
-  @property({type: Boolean})
-  _hideQuickApproveAction = false;
+  // private but used in test
+  @state() _hideQuickApproveAction = false;
 
   @property({type: Object})
   account?: AccountInfo;
@@ -422,7 +434,7 @@
   @property({type: String})
   commitNum?: CommitId;
 
-  @property({type: Boolean, observer: '_computeChainState'})
+  @property({type: Boolean})
   hasParent?: boolean;
 
   @property({type: String})
@@ -431,58 +443,39 @@
   @property({type: String})
   commitMessage = '';
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   revisionActions: ActionNameToActionInfoMap = {};
 
-  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
-  _revisionSubmitAction?: ActionInfo | null;
+  @state() private revisionSubmitAction?: ActionInfo | null;
 
-  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
-  _revisionRebaseAction?: ActionInfo | null;
+  // used as a proprty type so cannot be private
+  @state() revisionRebaseAction?: ActionInfo | null;
 
   @property({type: String})
   privateByDefault?: InheritedBooleanInfo;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _actionLoadingMessage = '';
+  // private but used in test
+  @state() actionLoadingMessage = '';
 
-  @property({
-    type: Array,
-    computed:
-      '_computeAllActions(actions.*, revisionActions.*,' +
-      'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_actionPriorityOverrides.*)',
-  })
-  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+  // _computeAllActions always returns an array
+  // private but used in test
+  @state() allActionValues: UIActionInfo[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeTopLevelActions(_allActionValues.*, ' +
-      '_hiddenActions.*, editMode, _overflowActions.*)',
-    observer: '_filterPrimaryActions',
-  })
-  _topLevelActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelPrimaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelSecondaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeMenuActions(_allActionValues.*, ' +
-      '_hiddenActions.*, _overflowActions.*)',
-  })
-  _menuActions?: MenuAction[];
+  @state() private menuActions?: MenuAction[];
 
-  @property({type: Array})
-  _overflowActions: OverflowAction[] = [
+  @state() private overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -529,17 +522,15 @@
     },
   ];
 
-  @property({type: Array})
-  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @property({type: Array})
-  _additionalActions: UIActionInfo[] = [];
+  @state() private additionalActions: UIActionInfo[] = [];
 
-  @property({type: Array})
-  _hiddenActions: string[] = [];
+  // private but used in test
+  @state() hiddenActions: string[] = [];
 
-  @property({type: Array})
-  _disabledMenuActions: string[] = [];
+  // private but used in test
+  @state() disabledMenuActions: string[] = [];
 
   @property({type: Boolean})
   editPatchsetLoaded = false;
@@ -550,9 +541,6 @@
   @property({type: Boolean})
   editBasedOnCurrentPatchSet = true;
 
-  @property({type: Object})
-  _config?: ServerInfo;
-
   @property({type: Boolean})
   loggedIn = false;
 
@@ -563,31 +551,385 @@
   constructor() {
     super();
     this.addEventListener('fullscreen-overlay-opened', () =>
-      this._handleHideBackgroundContent()
+      this.handleHideBackgroundContent()
     );
     this.addEventListener('fullscreen-overlay-closed', () =>
-      this._handleShowBackgroundContent()
+      this.handleShowBackgroundContent()
     );
   }
 
-  override ready() {
-    super.ready();
+  override connectedCallback() {
+    super.connectedCallback();
     this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-    this._handleLoadingComplete();
+    this.handleLoadingComplete();
   }
 
-  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'submit');
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: flex;
+          font-family: var(--font-family);
+        }
+        #actionLoadingMessage,
+        #mainContent,
+        section {
+          display: flex;
+        }
+        #actionLoadingMessage,
+        gr-button,
+        gr-dropdown {
+          /* px because don't have the same font size */
+          margin-left: 8px;
+        }
+        gr-button {
+          display: block;
+        }
+        #actionLoadingMessage {
+          align-items: center;
+          color: var(--deemphasized-text-color);
+        }
+        #confirmSubmitDialog .changeSubject {
+          margin: var(--spacing-l);
+          text-align: center;
+        }
+        iron-icon {
+          color: inherit;
+          margin-right: var(--spacing-xs);
+        }
+        #moreActions iron-icon {
+          margin: 0;
+        }
+        #moreMessage,
+        .hidden {
+          display: none;
+        }
+        @media screen and (max-width: 50em) {
+          #mainContent {
+            flex-wrap: wrap;
+          }
+          gr-button {
+            --gr-button-padding: var(--spacing-m);
+            white-space: nowrap;
+          }
+          gr-button,
+          gr-dropdown {
+            margin: 0;
+          }
+          #actionLoadingMessage {
+            margin: var(--spacing-m);
+            text-align: center;
+          }
+          #moreMessage {
+            display: inline;
+          }
+        }
+      `,
+    ];
   }
 
-  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'rebase');
+  override render() {
+    if (!this.change) return nothing;
+    return html`
+      <div id="mainContent">
+        <span id="actionLoadingMessage" ?hidden=${!this.actionLoadingMessage}>
+          ${this.actionLoadingMessage}
+        </span>
+        <section
+          id="primaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelPrimaryActions?.map(action =>
+            this.renderTopPrimaryActions(action)
+          )}
+        </section>
+        <section
+          id="secondaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelSecondaryActions?.map(action =>
+            this.renderTopSecondaryActions(action)
+          )}
+        </section>
+        <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
+        <gr-dropdown
+          id="moreActions"
+          link
+          .verticalOffset=${32}
+          .horizontalAlign=${'right'}
+          @tap-item=${this.handleOverflowItemTap}
+          ?hidden=${this.loading ||
+          !this.menuActions ||
+          !this.menuActions.length}
+          .disabledIds=${this.disabledMenuActions}
+          .items=${this.menuActions}
+        >
+          <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+          </iron-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>
+      <gr-overlay id="overlay" with-backdrop="">
+        <gr-confirm-rebase-dialog
+          id="confirmRebase"
+          class="confirmDialog"
+          .changeNumber=${this.change?._number}
+          @confirm=${this.handleRebaseConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .branch=${this.change?.branch}
+          .hasParent=${this.hasParent}
+          .rebaseOnCurrent=${this.revisionRebaseAction
+            ? !!this.revisionRebaseAction.enabled
+            : null}
+        ></gr-confirm-rebase-dialog>
+        <gr-confirm-cherrypick-dialog
+          id="confirmCherrypick"
+          class="confirmDialog"
+          .changeStatus=${this.changeStatus}
+          .commitMessage=${this.commitMessage}
+          .commitNum=${this.commitNum}
+          @confirm=${this.handleCherrypickConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-cherrypick-dialog>
+        <gr-confirm-cherrypick-conflict-dialog
+          id="confirmCherrypickConflict"
+          class="confirmDialog"
+          @confirm=${this.handleCherrypickConflictConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-cherrypick-conflict-dialog>
+        <gr-confirm-move-dialog
+          id="confirmMove"
+          class="confirmDialog"
+          @confirm=${this.handleMoveConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-move-dialog>
+        <gr-confirm-revert-dialog
+          id="confirmRevertDialog"
+          class="confirmDialog"
+          @confirm=${this.handleRevertDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-revert-dialog>
+        <gr-confirm-abandon-dialog
+          id="confirmAbandonDialog"
+          class="confirmDialog"
+          @confirm=${this.handleAbandonDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-abandon-dialog>
+        <gr-confirm-submit-dialog
+          id="confirmSubmitDialog"
+          class="confirmDialog"
+          .action=${this.revisionSubmitAction}
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleSubmitConfirm}
+        ></gr-confirm-submit-dialog>
+        <gr-dialog
+          id="createFollowUpDialog"
+          class="confirmDialog"
+          confirm-label="Create"
+          @confirm=${this.handleCreateFollowUpChange}
+          @cancel=${this.handleCloseCreateFollowUpChange}
+        >
+          <div class="header" slot="header">Create Follow-Up Change</div>
+          <div class="main" slot="main">
+            <gr-create-change-dialog
+              id="createFollowUpChange"
+              .branch=${this.change?.branch}
+              .baseChange=${this.change?.id}
+              .repoName=${this.change?.project}
+              .privateByDefault=${this.privateByDefault}
+            ></gr-create-change-dialog>
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteConfirm}
+        >
+          <div class="header" slot="header">Delete Change</div>
+          <div class="main" slot="main">
+            Do you really want to delete the change?
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteEditConfirm}
+        >
+          <div class="header" slot="header">Delete Change Edit</div>
+          <div class="main" slot="main">
+            Do you really want to delete the edit?
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
   }
 
-  _getRevisionAction(
+  private renderTopPrimaryActions(action: UIActionInfo) {
+    return html`
+      <gr-tooltip-content
+        title=${ifDefined(action.title)}
+        .hasTooltip=${!!action.title}
+        ?position-below=${true}
+      >
+        <gr-button
+          link
+          class=${action.__key}
+          data-action-key=${action.__key}
+          data-label=${action.label}
+          ?disabled=${this.calculateDisabled(action)}
+          @click=${(e: MouseEvent) =>
+            this.handleActionTap(e, action.__key, action.__type)}
+        >
+          <iron-icon
+            class=${action.icon ? '' : 'hidden'}
+            .icon="gr-icons:${action.icon}"
+          ></iron-icon>
+          ${action.label}
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderTopSecondaryActions(action: UIActionInfo) {
+    return html`
+      <gr-tooltip-content
+        title=${ifDefined(action.title)}
+        .hasTooltip=${!!action.title}
+        ?position-below=${true}
+      >
+        <gr-button
+          link
+          class=${action.__key}
+          data-action-key=${action.__key}
+          data-label=${action.label}
+          ?disabled=${this.calculateDisabled(action)}
+          @click=${(e: MouseEvent) =>
+            this.handleActionTap(e, action.__key, action.__type)}
+        >
+          <iron-icon
+            class=${action.icon ? '' : 'hidden'}
+            icon="gr-icons:${action.icon}"
+          ></iron-icon>
+          ${action.label}
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasParent')) {
+      this.computeChainState();
+    }
+
+    if (changedProperties.has('revisionActions')) {
+      this.revisionSubmitAction = this.getSubmitAction(this.revisionActions);
+      this.revisionRebaseAction = this.getRebaseAction(this.revisionActions);
+    }
+
+    if (
+      changedProperties.has('actions') ||
+      changedProperties.has('revisionActions') ||
+      changedProperties.has('primaryActionKeys') ||
+      changedProperties.has('additionalActions') ||
+      changedProperties.has('change') ||
+      changedProperties.has('actionPriorityOverrides')
+    ) {
+      this.allActionValues = this.computeAllActions(
+        this.actions,
+        this.revisionActions,
+        this.primaryActionKeys,
+        this.additionalActions,
+        this.change
+      );
+    }
+
+    if (
+      changedProperties.has('allActionValues') ||
+      changedProperties.has('hiddenActions') ||
+      changedProperties.has('editMode') ||
+      changedProperties.has('overflowActions')
+    ) {
+      this.topLevelActions = this.computeTopLevelActions(
+        this.allActionValues,
+        this.hiddenActions,
+        this.editMode
+      );
+    }
+
+    if (changedProperties.has('topLevelActions')) {
+      this.filterPrimaryActions(this.topLevelActions);
+    }
+
+    if (
+      changedProperties.has('allActionValues') ||
+      changedProperties.has('hiddenActions') ||
+      changedProperties.has('overflowActions')
+    ) {
+      this.menuActions = this.computeMenuActions(
+        this.allActionValues,
+        this.hiddenActions
+      );
+    }
+
+    if (changedProperties.has('change')) {
+      this.reload();
+    }
+
+    if (
+      changedProperties.has('actions') ||
+      changedProperties.has('revisionActions') ||
+      changedProperties.has('additionalActions')
+    ) {
+      this.actionsChanged(
+        this.actions,
+        this.revisionActions,
+        this.additionalActions
+      );
+    }
+
+    if (
+      changedProperties.has('editMode') ||
+      changedProperties.has('editPatchsetLoaded') ||
+      changedProperties.has('editBasedOnCurrentPatchSet') ||
+      changedProperties.has('disableEdit') ||
+      changedProperties.has('loggedIn') ||
+      changedProperties.has('actions') ||
+      changedProperties.has('change')
+    ) {
+      this.editStatusChanged(
+        this.editMode,
+        this.editPatchsetLoaded,
+        this.editBasedOnCurrentPatchSet,
+        this.disableEdit,
+        this.loggedIn,
+        this.actions,
+        this.change
+      );
+    }
+  }
+
+  private getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'submit');
+  }
+
+  private getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'rebase');
+  }
+
+  private getRevisionAction(
     revisionActions: ActionNameToActionInfoMap,
     actionName: string
   ) {
@@ -608,7 +950,7 @@
     }
     const change = this.change;
 
-    this._loading = true;
+    this.loading = true;
     return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
@@ -617,37 +959,33 @@
         }
 
         this.revisionActions = revisionActions;
-        this._sendShowRevisionActions({
+        this.sendShowRevisionActions({
           change,
           revisionActions,
         });
-        this._handleLoadingComplete();
+        this.handleLoadingComplete();
       })
       .catch(err => {
         fireAlert(this, ERR_REVISION_ACTIONS);
-        this._loading = false;
+        this.loading = false;
         throw err;
       });
   }
 
-  _handleLoadingComplete() {
+  private handleLoadingComplete() {
     getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => (this._loading = false));
+      .then(() => (this.loading = false));
   }
 
-  _sendShowRevisionActions(detail: {
+  // private but used in test
+  sendShowRevisionActions(detail: {
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
     this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
-  @observe('change')
-  _changeChanged() {
-    this.reload();
-  }
-
   addActionButton(type: ActionType, label: string) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type: ${type}`);
@@ -659,16 +997,18 @@
       __key:
         ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
     };
-    this.push('_additionalActions', action);
+    this.additionalActions.push(action);
+    this.requestUpdate('additionalActions');
     return action.__key;
   }
 
   removeActionButton(key: string) {
-    const idx = this._indexOfActionButtonWithKey(key);
+    const idx = this.indexOfActionButtonWithKey(key);
     if (idx === -1) {
       return;
     }
-    this.splice('_additionalActions', idx, 1);
+    this.additionalActions.splice(idx, 1);
+    this.requestUpdate('additionalActions');
   }
 
   setActionButtonProp<T extends keyof UIActionInfo>(
@@ -676,26 +1016,26 @@
     prop: T,
     value: UIActionInfo[T]
   ) {
-    this.set(
-      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
-      value
-    );
+    this.additionalActions[this.indexOfActionButtonWithKey(key)][prop] = value;
+    this.requestUpdate('additionalActions');
   }
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._getActionOverflowIndex(type, key);
+    const index = this.getActionOverflowIndex(type, key);
     const action: OverflowAction = {
       type,
       key,
       overflow,
     };
     if (!overflow && index !== -1) {
-      this.splice('_overflowActions', index, 1);
+      this.overflowActions.splice(index, 1);
+      this.requestUpdate('overflowActions');
     } else if (overflow) {
-      this.push('_overflowActions', action);
+      this.overflowActions.push(action);
+      this.requestUpdate('overflowActions');
     }
   }
 
@@ -707,7 +1047,7 @@
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._actionPriorityOverrides.findIndex(
+    const index = this.actionPriorityOverrides.findIndex(
       action => action.type === type && action.key === key
     );
     const action: ActionPriorityOverride = {
@@ -716,9 +1056,11 @@
       priority,
     };
     if (index !== -1) {
-      this.set('_actionPriorityOverrides', index, action);
+      this.actionPriorityOverrides[index] = action;
+      this.requestUpdate('actionPriorityOverrides');
     } else {
-      this.push('_actionPriorityOverrides', action);
+      this.actionPriorityOverrides.push(action);
+      this.requestUpdate('actionPriorityOverrides');
     }
   }
 
@@ -731,11 +1073,13 @@
       throw Error(`Invalid action type given: ${type}`);
     }
 
-    const idx = this._hiddenActions.indexOf(key);
+    const idx = this.hiddenActions.indexOf(key);
     if (hidden && idx === -1) {
-      this.push('_hiddenActions', key);
+      this.hiddenActions.push(key);
+      this.requestUpdate('hiddenActions');
     } else if (!hidden && idx !== -1) {
-      this.splice('_hiddenActions', idx, 1);
+      this.hiddenActions.splice(idx, 1);
+      this.requestUpdate('hiddenActions');
     }
   }
 
@@ -749,182 +1093,165 @@
     }
   }
 
-  _indexOfActionButtonWithKey(key: string) {
-    for (let i = 0; i < this._additionalActions.length; i++) {
-      if (this._additionalActions[i].__key === key) {
+  private indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this.additionalActions.length; i++) {
+      if (this.additionalActions[i].__key === key) {
         return i;
       }
     }
     return -1;
   }
 
-  _shouldHideActions(
-    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    loading?: boolean
-  ) {
-    return loading || !actions || !actions.base || !actions.base.length;
-  }
-
-  _keyCount(
-    changeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >
-  ) {
-    return Object.keys(changeRecord?.base || {}).length;
-  }
-
-  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
-  _actionsChanged(
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >
+  private actionsChanged(
+    actionsChange?: ActionNameToActionInfoMap,
+    revisionActionsChange?: ActionNameToActionInfoMap,
+    additionalActionsChange?: UIActionInfo[]
   ) {
     // Polymer 2: check for undefined
     if (
-      actionsChangeRecord === undefined ||
-      revisionActionsChangeRecord === undefined ||
-      additionalActionsChangeRecord === undefined
+      actionsChange === undefined ||
+      revisionActionsChange === undefined ||
+      additionalActionsChange === undefined
     ) {
       return;
     }
 
-    const additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
     this.hidden =
-      this._keyCount(actionsChangeRecord) === 0 &&
-      this._keyCount(revisionActionsChangeRecord) === 0 &&
-      additionalActions.length === 0;
-    this._actionLoadingMessage = '';
-    this._actionLoadingMessage = '';
-    this._disabledMenuActions = [];
+      Object.keys(actionsChange).length === 0 &&
+      Object.keys(revisionActionsChange).length === 0 &&
+      additionalActionsChange.length === 0;
+    this.actionLoadingMessage = '';
+    this.disabledMenuActions = [];
 
-    const revisionActions = revisionActionsChangeRecord.base || {};
-    if (Object.keys(revisionActions).length !== 0) {
-      if (!revisionActions.download) {
-        this.set('revisionActions.download', DOWNLOAD_ACTION);
+    if (Object.keys(revisionActionsChange).length !== 0) {
+      if (!revisionActionsChange.download) {
+        this.revisionActions = {
+          ...this.revisionActions,
+          download: DOWNLOAD_ACTION,
+        };
+        fire(this, 'revision-actions-changed', {
+          value: this.revisionActions,
+        });
       }
     }
-    const actions = actionsChangeRecord.base || {};
-    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
-      this.set('actions.includedIn', INCLUDED_IN_ACTION);
+    if (
+      !actionsChange.includedIn &&
+      this.change?.status === ChangeStatus.MERGED
+    ) {
+      this.actions = {...this.actions, includedIn: INCLUDED_IN_ACTION};
     }
   }
 
-  _deleteAndNotify(actionName: string) {
+  private deleteAndNotify(actionName: string) {
     if (this.actions && this.actions[actionName]) {
       delete this.actions[actionName];
-      // We assign a fake value of 'false' to support Polymer 2
-      // see https://github.com/Polymer/polymer/issues/2631
-      this.notifyPath('actions.' + actionName, false);
+      this.refreshActions();
     }
   }
 
-  @observe(
-    'editMode',
-    'editPatchsetLoaded',
-    'editBasedOnCurrentPatchSet',
-    'disableEdit',
-    'loggedIn',
-    'actions.*',
-    'change.*'
-  )
-  _editStatusChanged(
+  // private but used in test
+  editStatusChanged(
     editMode: boolean,
     editPatchsetLoaded: boolean,
     editBasedOnCurrentPatchSet: boolean,
     disableEdit: boolean,
     loggedIn: boolean,
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+    actionsChange?: ActionNameToActionInfoMap,
+    changeChange?: ChangeInfo
   ) {
     // Hide change edits if not logged in
     if (
-      actionsChangeRecord === undefined ||
-      changeChangeRecord === undefined ||
+      actionsChange === undefined ||
+      changeChange === undefined ||
       !loggedIn
     ) {
       return;
     }
     if (disableEdit) {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-      this._deleteAndNotify('stopEdit');
-      this._deleteAndNotify('edit');
+      this.deleteAndNotify('publishEdit');
+      this.deleteAndNotify('rebaseEdit');
+      this.deleteAndNotify('deleteEdit');
+      this.deleteAndNotify('stopEdit');
+      this.deleteAndNotify('edit');
       return;
     }
-    const actions = actionsChangeRecord.base;
-    const change = changeChangeRecord.base;
-    if (actions && editPatchsetLoaded) {
+    if (actionsChange && editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
-      if (changeIsOpen(change)) {
+      if (changeIsOpen(changeChange)) {
         if (editBasedOnCurrentPatchSet) {
-          if (!actions.publishEdit) {
-            this.set('actions.publishEdit', PUBLISH_EDIT);
+          if (!actionsChange.publishEdit) {
+            this.actions = {...this.actions, publishEdit: PUBLISH_EDIT};
           }
-          this._deleteAndNotify('rebaseEdit');
+          this.deleteAndNotify('rebaseEdit');
         } else {
-          if (!actions.rebaseEdit) {
-            this.set('actions.rebaseEdit', REBASE_EDIT);
+          if (!actionsChange.rebaseEdit) {
+            this.actions = {...this.actions, rebaseEdit: REBASE_EDIT};
           }
-          this._deleteAndNotify('publishEdit');
+          this.deleteAndNotify('publishEdit');
         }
       }
-      if (!actions.deleteEdit) {
-        this.set('actions.deleteEdit', DELETE_EDIT);
+      if (!actionsChange.deleteEdit) {
+        this.actions = {...this.actions, deleteEdit: DELETE_EDIT};
       }
     } else {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
+      this.deleteAndNotify('publishEdit');
+      this.deleteAndNotify('rebaseEdit');
+      this.deleteAndNotify('deleteEdit');
     }
 
-    if (actions && changeIsOpen(change)) {
+    if (actionsChange && changeIsOpen(changeChange)) {
       // Only show edit button if there is no edit patchset loaded and the
       // file list is not in edit mode.
       if (editPatchsetLoaded || editMode) {
-        this._deleteAndNotify('edit');
+        this.deleteAndNotify('edit');
       } else {
-        if (!actions.edit) {
-          this.set('actions.edit', EDIT);
+        if (!actionsChange.edit) {
+          this.actions = {...this.actions, edit: EDIT};
         }
       }
       // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
       // is loaded.
       if (editMode && !editPatchsetLoaded) {
-        if (!actions.stopEdit) {
-          this.set('actions.stopEdit', STOP_EDIT);
+        if (!actionsChange.stopEdit) {
+          this.actions = {...this.actions, stopEdit: STOP_EDIT};
           fireAlert(this, 'Change is in edit mode');
         }
       } else {
-        this._deleteAndNotify('stopEdit');
+        this.deleteAndNotify('stopEdit');
       }
     } else {
       // Remove edit button.
-      this._deleteAndNotify('edit');
+      this.deleteAndNotify('edit');
     }
+
+    this.refreshActions();
   }
 
-  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+  // It appears that trying to get life cycles to be re-triggered
+  // from a function being called by a life cycle does not work.
+  // So we have to manually do this.
+  private refreshActions() {
+    this.allActionValues = this.computeAllActions(
+      this.actions,
+      this.revisionActions,
+      this.primaryActionKeys,
+      this.additionalActions,
+      this.change
+    );
+    this.topLevelActions = this.computeTopLevelActions(
+      this.allActionValues,
+      this.hiddenActions,
+      this.editMode
+    );
+    this.filterPrimaryActions(this.topLevelActions);
+  }
+
+  private getValuesFor<T>(obj: {[key: string]: T}): T[] {
     return Object.keys(obj).map(key => obj[key]);
   }
 
-  _getLabelStatus(label: LabelInfo): LabelStatus {
+  private getLabelStatus(label: LabelInfo): LabelStatus {
     if (isQuickLabelInfo(label)) {
       if (label.approved) {
         return LabelStatus.OK;
@@ -943,7 +1270,7 @@
    * Get highest score for last missing permitted label for current change.
    * Returns null if no labels permitted or more than one label missing.
    */
-  _getTopMissingApproval() {
+  private getTopMissingApproval() {
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
@@ -958,7 +1285,7 @@
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(labelInfo);
+      const status = this.getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so check if Code Review can be
@@ -1014,20 +1341,20 @@
   }
 
   hideQuickApproveAction() {
-    if (!this._topLevelSecondaryActions) {
-      throw new Error('_topLevelSecondaryActions must be set');
+    if (!this.topLevelSecondaryActions) {
+      throw new Error('topLevelSecondaryActions must be set');
     }
-    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+    this.topLevelSecondaryActions = this.topLevelSecondaryActions.filter(
       sa => !isQuickApproveAction(sa)
     );
     this._hideQuickApproveAction = true;
   }
 
-  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+  private getQuickApproveAction(): QuickApproveUIActionInfo | null {
     if (this._hideQuickApproveAction) {
       return null;
     }
-    const approval = this._getTopMissingApproval();
+    const approval = this.getTopMissingApproval();
     if (!approval) {
       return null;
     }
@@ -1049,32 +1376,23 @@
     return action;
   }
 
-  _getActionValues(
-    actionsChangeRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesChangeRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsChangeRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
+  private getActionValues(
+    actionsChange: ActionNameToActionInfoMap,
+    primariesChange: PrimaryActionKey[],
+    additionalActionsChange: UIActionInfo[],
     type: ActionType
   ): UIActionInfo[] {
-    if (!actionsChangeRecord || !primariesChangeRecord) {
+    if (!actionsChange || !primariesChange) {
       return [];
     }
 
-    const actions = actionsChangeRecord.base || {};
-    const primaryActionKeys = primariesChangeRecord.base || [];
+    const actions = actionsChange;
+    const primaryActionKeys = primariesChange;
     const result: UIActionInfo[] = [];
     const values: Array<ChangeActions | RevisionActions> =
       type === ActionType.CHANGE
-        ? this._getValuesFor(ChangeActions)
-        : this._getValuesFor(RevisionActions);
+        ? this.getValuesFor(ChangeActions)
+        : this.getValuesFor(RevisionActions);
 
     const pluginActions: UIActionInfo[] = [];
     Object.keys(actions).forEach(a => {
@@ -1084,26 +1402,25 @@
       action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
       // Plugin actions always contain ~ in the key.
       if (a.indexOf('~') !== -1) {
-        this._populateActionUrl(action);
+        this.populateActionUrl(action);
         pluginActions.push(action);
         // Add server-side provided plugin actions to overflow menu.
-        this._overflowActions.push({
+        this.overflowActions.push({
           type,
           key: a,
         });
+        this.requestUpdate('overflowActions');
         return;
       } else if (!values.includes(a as PrimaryActionKey)) {
         return;
       }
-      action.label = this._getActionLabel(action);
+      action.label = this.getActionLabel(action);
 
       // Triggers a re-render by ensuring object inequality.
       result.push({...action});
     });
 
-    let additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
+    let additionalActions = additionalActionsChange;
     additionalActions = additionalActions
       .filter(a => a.__type === type)
       .map(a => {
@@ -1114,7 +1431,7 @@
     return result.concat(additionalActions).concat(pluginActions);
   }
 
-  _populateActionUrl(action: UIActionInfo) {
+  private populateActionUrl(action: UIActionInfo) {
     const patchNum =
       action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
     if (!this.changeNum) {
@@ -1129,7 +1446,7 @@
    * Given a change action, return a display label that uses the appropriate
    * casing or includes explanatory details.
    */
-  _getActionLabel(action: UIActionInfo) {
+  private getActionLabel(action: UIActionInfo) {
     if (action.label === 'Delete') {
       // This label is common within change and revision actions. Make it more
       // explicit to the user.
@@ -1138,34 +1455,38 @@
       return 'Mark as work in progress';
     }
     // Otherwise, just map the name to sentence case.
-    return this._toSentenceCase(action.label);
+    return this.toSentenceCase(action.label);
   }
 
   /**
    * Capitalize the first letter and lowecase all others.
+   *
+   * private but used in test
    */
-  _toSentenceCase(s: string) {
+  toSentenceCase(s: string) {
     if (!s.length) {
       return '';
     }
     return s[0].toUpperCase() + s.slice(1).toLowerCase();
   }
 
-  _computeLoadingLabel(action: string) {
+  private computeLoadingLabel(action: string) {
     return ActionLoadingLabels[action] || 'Working...';
   }
 
-  _canSubmitChange() {
+  // private but used in test
+  canSubmitChange() {
     if (!this.change) {
       return false;
     }
     return this.jsAPI.canSubmitChange(
       this.change,
-      this._getRevision(this.change, this.latestPatchNum)
+      this.getRevision(this.change, this.latestPatchNum)
     );
   }
 
-  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+  // private but used in test
+  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
       if (rev._number === patchNum) {
         return rev;
@@ -1187,21 +1508,23 @@
         this.reporting.error(new Error('changes is undefined'));
         return;
       }
-      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
-      this._showActionDialog(this.$.confirmRevertDialog);
+      assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+      this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this.showActionDialog(this.confirmRevertDialog);
     });
   }
 
   showSubmitDialog() {
-    if (!this._canSubmitChange()) {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._showActionDialog(this.$.confirmSubmitDialog);
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    this.showActionDialog(this.confirmSubmitDialog);
   }
 
-  _handleActionTap(e: MouseEvent) {
+  private handleActionTap(e: MouseEvent, key: string, type: string) {
     e.preventDefault();
-    let el = (dom(e) as EventApi).localTarget as Element;
+    let el = e.target as Element;
     while (el.tagName.toLowerCase() !== 'gr-button') {
       if (!el.parentElement) {
         return;
@@ -1209,10 +1532,6 @@
       el = el.parentElement;
     }
 
-    const key = el.getAttribute('data-action-key');
-    if (!key) {
-      throw new Error("Button doesn't have data-action-key attribute");
-    }
     if (
       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
       key.indexOf('~') !== -1
@@ -1226,11 +1545,10 @@
       );
       return;
     }
-    const type = el.getAttribute('data-action-type') as ActionType;
-    this._handleAction(type, key);
+    this.handleAction(type as ActionType, key);
   }
 
-  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+  private handleOverflowItemTap(e: CustomEvent<MenuAction>) {
     e.preventDefault();
     const el = (dom(e) as EventApi).localTarget as Element;
     const key = e.detail.action.__key;
@@ -1247,147 +1565,160 @@
       );
       return;
     }
-    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    this.handleAction(e.detail.action.__type, e.detail.action.__key);
   }
 
-  _handleAction(type: ActionType, key: string) {
+  // private but used in test
+  handleAction(type: ActionType, key: string) {
     this.reporting.reportInteraction(`${type}-${key}`);
     switch (type) {
       case ActionType.REVISION:
-        this._handleRevisionAction(key);
+        this.handleRevisionAction(key);
         break;
       case ActionType.CHANGE:
-        this._handleChangeAction(key);
+        this.handleChangeAction(key);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleChangeAction(key: string) {
+  // private but used in test
+  handleChangeAction(key: string) {
     switch (key) {
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
       case ChangeActions.ABANDON:
-        this._showActionDialog(this.$.confirmAbandonDialog);
+        assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+        this.showActionDialog(this.confirmAbandonDialog);
         break;
       case QUICK_APPROVE_ACTION.key: {
-        const action = this._allActionValues.find(isQuickApproveAction);
+        const action = this.allActionValues.find(isQuickApproveAction);
         if (!action) {
           return;
         }
-        this._fireAction(this._prependSlash(key), action, true, action.payload);
+        this.fireAction(this.prependSlash(key), action, true, action.payload);
         break;
       }
       case ChangeActions.EDIT:
-        this._handleEditTap();
+        this.handleEditTap();
         break;
       case ChangeActions.STOP_EDIT:
-        this._handleStopEditTap();
+        this.handleStopEditTap();
         break;
       case ChangeActions.DELETE:
-        this._handleDeleteTap();
+        this.handleDeleteTap();
         break;
       case ChangeActions.DELETE_EDIT:
-        this._handleDeleteEditTap();
+        this.handleDeleteEditTap();
         break;
       case ChangeActions.FOLLOW_UP:
-        this._handleFollowUpTap();
+        this.handleFollowUpTap();
         break;
       case ChangeActions.WIP:
-        this._handleWipTap();
+        this.handleWipTap();
         break;
       case ChangeActions.MOVE:
-        this._handleMoveTap();
+        this.handleMoveTap();
         break;
       case ChangeActions.PUBLISH_EDIT:
-        this._handlePublishEditTap();
+        this.handlePublishEditTap();
         break;
       case ChangeActions.REBASE_EDIT:
-        this._handleRebaseEditTap();
+        this.handleRebaseEditTap();
         break;
       case ChangeActions.INCLUDED_IN:
-        this._handleIncludedInTap();
+        this.handleIncludedInTap();
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleRevisionAction(key: string) {
+  private handleRevisionAction(key: string) {
     switch (key) {
       case RevisionActions.REBASE:
-        this._showActionDialog(this.$.confirmRebase);
-        this.$.confirmRebase.fetchRecentChanges();
+        assertIsDefined(this.confirmRebase, 'confirmRebase');
+        this.showActionDialog(this.confirmRebase);
+        this.confirmRebase.fetchRecentChanges();
         break;
       case RevisionActions.CHERRYPICK:
-        this._handleCherrypickTap();
+        this.handleCherrypickTap();
         break;
       case RevisionActions.DOWNLOAD:
-        this._handleDownloadTap();
+        this.handleDownloadTap();
         break;
       case RevisionActions.SUBMIT:
-        if (!this._canSubmitChange()) {
+        if (!this.canSubmitChange()) {
           return;
         }
-        this._showActionDialog(this.$.confirmSubmitDialog);
+        assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+        this.showActionDialog(this.confirmSubmitDialog);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.revisionActions[key]),
           true
         );
     }
   }
 
-  _prependSlash(key: string) {
+  private prependSlash(key: string) {
     return key === '/' ? key : `/${key}`;
   }
 
   /**
    * _hasKnownChainState set to true true if hasParent is defined (can be
    * either true or false). set to false otherwise.
+   *
+   * private but used in test
    */
-  _computeChainState() {
+  computeChainState() {
     this._hasKnownChainState = true;
   }
 
-  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+  // private but used in test
+  calculateDisabled(action: UIActionInfo) {
     if (action.__key === 'rebase') {
       // Rebase button is only disabled when change has no parent(s).
-      return hasKnownChainState === false;
+      return this._hasKnownChainState === false;
     }
     return !action.enabled;
   }
 
-  _handleConfirmDialogCancel() {
-    this._hideAllDialogs();
+  private handleConfirmDialogCancel() {
+    this.hideAllDialogs();
   }
 
-  _hideAllDialogs() {
-    const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+  private hideAllDialogs() {
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    const dialogEls = queryAll(this, '.confirmDialog');
     for (const dialogEl of dialogEls) {
       (dialogEl as HTMLElement).hidden = true;
     }
-    this.$.overlay.close();
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
-    const el = this.$.confirmRebase;
+  // private but used in test
+  handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    assertIsDefined(this.confirmRebase, 'confirmRebase');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmRebase;
     const payload = {base: e.detail.base};
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
       true,
@@ -1395,16 +1726,20 @@
     );
   }
 
-  _handleCherrypickConfirm() {
-    this._handleCherryPickRestApi(false);
+  // private but used in test
+  handleCherrypickConfirm() {
+    this.handleCherryPickRestApi(false);
   }
 
-  _handleCherrypickConflictConfirm() {
-    this._handleCherryPickRestApi(true);
+  // private but used in test
+  handleCherrypickConflictConfirm() {
+    this.handleCherryPickRestApi(true);
   }
 
-  _handleCherryPickRestApi(conflicts: boolean) {
-    const el = this.$.confirmCherrypick;
+  private handleCherryPickRestApi(conflicts: boolean) {
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmCherrypick;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
@@ -1413,9 +1748,9 @@
       fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/cherrypick',
       assertUIActionInfo(this.revisionActions.cherrypick),
       true,
@@ -1428,29 +1763,34 @@
     );
   }
 
-  _handleMoveConfirm() {
-    const el = this.$.confirmMove;
+  // private but used in test
+  handleMoveConfirm() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmMove;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+    this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
       destination_branch: el.branch,
       message: el.message,
     });
   }
 
-  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+  private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+    assertIsDefined(this.overlay, 'overlay');
     const revertType = e.detail.revertType;
     const message = e.detail.message;
-    const el = this.$.confirmRevertDialog;
-    this.$.overlay.close();
+    const el = this.confirmRevertDialog;
+    this.overlay.close();
     el.hidden = true;
     switch (revertType) {
       case RevertType.REVERT_SINGLE_CHANGE:
-        this._fireAction(
+        this.fireAction(
           '/revert',
           assertUIActionInfo(this.actions.revert),
           false,
@@ -1460,7 +1800,7 @@
       case RevertType.REVERT_SUBMISSION:
         // TODO(dhruvsri): replace with this.actions.revert_submission once
         // BE starts sending it again
-        this._fireAction(
+        this.fireAction(
           '/revert_submission',
           {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
@@ -1472,11 +1812,14 @@
     }
   }
 
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    this.$.overlay.close();
+  // private but used in test
+  handleAbandonDialogConfirm() {
+    assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+    assertIsDefined(this.overlay, 'overlay');
+    const el = this.confirmAbandonDialog;
+    this.overlay.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/abandon',
       assertUIActionInfo(this.actions.abandon),
       false,
@@ -1486,58 +1829,62 @@
     );
   }
 
-  _handleCreateFollowUpChange() {
-    this.$.createFollowUpChange.handleCreateChange();
-    this._handleCloseCreateFollowUpChange();
+  private handleCreateFollowUpChange() {
+    assertIsDefined(this.createFollowUpChange, 'createFollowUpChange');
+    this.createFollowUpChange.handleCreateChange();
+    this.handleCloseCreateFollowUpChange();
   }
 
-  _handleCloseCreateFollowUpChange() {
-    this.$.overlay.close();
+  private handleCloseCreateFollowUpChange() {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleDeleteConfirm() {
-    this._hideAllDialogs();
-    this._fireAction(
+  private handleDeleteConfirm() {
+    this.hideAllDialogs();
+    this.fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
       false
     );
   }
 
-  _handleDeleteEditConfirm() {
-    this._hideAllDialogs();
+  private handleDeleteEditConfirm() {
+    this.hideAllDialogs();
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
     this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
 
-    this._fireAction(
+    this.fireAction(
       '/edit',
       assertUIActionInfo(this.actions.deleteEdit),
       false
     );
   }
 
-  _handleSubmitConfirm() {
-    if (!this._canSubmitChange()) {
+  // private but used in test
+  handleSubmitConfirm() {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._hideAllDialogs();
-    this._fireAction(
+    this.hideAllDialogs();
+    this.fireAction(
       '/submit',
       assertUIActionInfo(this.revisionActions.submit),
       true
     );
   }
 
-  _getActionOverflowIndex(type: string, key: string) {
-    return this._overflowActions.findIndex(
+  private getActionOverflowIndex(type: string, key: string) {
+    return this.overflowActions.findIndex(
       action => action.type === type && action.key === key
     );
   }
 
-  _setLoadingOnButtonWithKey(type: string, key: string) {
-    this._actionLoadingMessage = this._computeLoadingLabel(key);
+  // private but used in test
+  setLoadingOnButtonWithKey(type: string, key: string) {
+    this.actionLoadingMessage = this.computeLoadingLabel(key);
     let buttonKey = key;
     // TODO(dhruvsri): clean this up later
     // If key is revert-submission, then button key should be 'revert'
@@ -1547,14 +1894,12 @@
     }
 
     // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push(
-        '_disabledMenuActions',
-        buttonKey === '/' ? 'delete' : buttonKey
-      );
+    if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
+      this.requestUpdate('disabledMenuActions');
       return () => {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
+        this.actionLoadingMessage = '';
+        this.disabledMenuActions = [];
       };
     }
 
@@ -1568,38 +1913,41 @@
     buttonEl.setAttribute('loading', 'true');
     buttonEl.disabled = true;
     return () => {
-      this._actionLoadingMessage = '';
+      this.actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
     };
   }
 
-  _fireAction(
+  // private but used in test
+  fireAction(
     endpoint: string,
     action: UIActionInfo,
     revAction: boolean,
     payload?: RequestPayload
   ) {
-    const cleanupFn = this._setLoadingOnButtonWithKey(
+    const cleanupFn = this.setLoadingOnButtonWithKey(
       action.__type,
       action.__key
     );
 
-    this._send(
+    this.send(
       action.method,
       payload,
       endpoint,
       revAction,
       cleanupFn,
       action
-    ).then(res => this._handleResponse(action, res));
+    ).then(res => this.handleResponse(action, res));
   }
 
-  _showActionDialog(dialog: ChangeActionDialog) {
-    this._hideAllDialogs();
+  // private but used in test
+  showActionDialog(dialog: ChangeActionDialog) {
+    this.hideAllDialogs();
     if (dialog.init) dialog.init();
     dialog.hidden = false;
-    this.$.overlay.open().then(() => {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.open().then(() => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
       }
@@ -1608,7 +1956,8 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setReviewOnRevert(newChangeId: NumericChangeId) {
+  // private but used in test
+  setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.jsAPI.getReviewPostRevert(this.change);
     if (!review) {
       return Promise.resolve(undefined);
@@ -1616,7 +1965,8 @@
     return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
-  _handleResponse(action: UIActionInfo, response?: Response) {
+  // private but used in test
+  handleResponse(action: UIActionInfo, response?: Response) {
     if (!response) {
       return;
     }
@@ -1624,8 +1974,8 @@
       switch (action.__key) {
         case ChangeActions.REVERT: {
           const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(revertChangeInfo._number)
-            .then(() => this._setReviewOnRevert(revertChangeInfo._number))
+          this.waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this.setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
               GerritNav.navigateToChange(revertChangeInfo);
             });
@@ -1633,11 +1983,9 @@
         }
         case RevisionActions.CHERRYPICK: {
           const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
-            () => {
-              GerritNav.navigateToChange(cherrypickChangeInfo);
-            }
-          );
+          this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
+            GerritNav.navigateToChange(cherrypickChangeInfo);
+          });
           break;
         }
         case ChangeActions.DELETE:
@@ -1661,7 +2009,7 @@
           )
             return;
           /* If there is only 1 change then gerrit will automatically
-             redirect to that change */
+            redirect to that change */
           GerritNav.navigateToSearchQuery(
             `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
           );
@@ -1674,7 +2022,8 @@
     });
   }
 
-  _handleResponseError(
+  // private but used in test
+  handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
     body?: RequestPayload
@@ -1696,7 +2045,11 @@
         body &&
         !(body as CherryPickInput).allow_conflicts
       ) {
-        this._showActionDialog(this.$.confirmCherrypickConflict);
+        assertIsDefined(
+          this.confirmCherrypickConflict,
+          'confirmCherrypickConflict'
+        );
+        this.showActionDialog(this.confirmCherrypickConflict);
         return;
       }
     }
@@ -1714,7 +2067,8 @@
     });
   }
 
-  _send(
+  // private but used in test
+  send(
     method: HttpMethod | undefined,
     payload: RequestPayload | undefined,
     actionEndpoint: string,
@@ -1724,7 +2078,7 @@
   ): Promise<Response | undefined> {
     const handleError: ErrorCallback = response => {
       cleanupFn.call(this);
-      this._handleResponseError(action, response, payload);
+      this.handleResponseError(action, response, payload);
     };
     const change = this.change;
     const changeNum = this.changeNum;
@@ -1774,11 +2128,13 @@
       });
   }
 
-  _handleCherrypickTap() {
+  // private but used in test
+  handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
     }
-    this.$.confirmCherrypick.branch = '' as BranchName;
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    this.confirmCherrypick.branch = '' as BranchName;
     const query = `topic: "${this.change.topic}"`;
     const options = listChangesOptionsToHex(
       ListChangesOption.MESSAGES,
@@ -1791,52 +2147,61 @@
           this.reporting.error(new Error('getChanges returns undefined'));
           return;
         }
-        this.$.confirmCherrypick.updateChanges(changes);
-        this._showActionDialog(this.$.confirmCherrypick);
+        this.confirmCherrypick!.updateChanges(changes);
+        this.showActionDialog(this.confirmCherrypick!);
       });
   }
 
-  _handleMoveTap() {
-    this.$.confirmMove.branch = '' as BranchName;
-    this.$.confirmMove.message = '';
-    this._showActionDialog(this.$.confirmMove);
+  // private but used in test
+  handleMoveTap() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    this.confirmMove.branch = '' as BranchName;
+    this.confirmMove.message = '';
+    this.showActionDialog(this.confirmMove);
   }
 
-  _handleDownloadTap() {
+  // private but used in test
+  handleDownloadTap() {
     fireEvent(this, 'download-tap');
   }
 
-  _handleIncludedInTap() {
+  // private but used in test
+  handleIncludedInTap() {
     fireEvent(this, 'included-tap');
   }
 
-  _handleDeleteTap() {
-    this._showActionDialog(this.$.confirmDeleteDialog);
+  // private but used in test
+  handleDeleteTap() {
+    assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+    this.showActionDialog(this.confirmDeleteDialog);
   }
 
-  _handleDeleteEditTap() {
-    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  // private but used in test
+  handleDeleteEditTap() {
+    assertIsDefined(this.confirmDeleteEditDialog, 'confirmDeleteEditDialog');
+    this.showActionDialog(this.confirmDeleteEditDialog);
   }
 
-  _handleFollowUpTap() {
-    this._showActionDialog(this.$.createFollowUpDialog);
+  private handleFollowUpTap() {
+    assertIsDefined(this.createFollowUpDialog, 'createFollowUpDialog');
+    this.showActionDialog(this.createFollowUpDialog);
   }
 
-  _handleWipTap() {
+  private handleWipTap() {
     if (!this.actions.wip) {
       return;
     }
-    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+    this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
   }
 
-  _handlePublishEditTap() {
+  private handlePublishEditTap() {
     if (!this.actions.publishEdit) return;
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
     this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
 
-    this._fireAction(
+    this.fireAction(
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
       false,
@@ -1844,93 +2209,85 @@
     );
   }
 
-  _handleRebaseEditTap() {
+  private handleRebaseEditTap() {
     if (!this.actions.rebaseEdit) {
       return;
     }
-    this._fireAction(
+    this.fireAction(
       '/edit:rebase',
       assertUIActionInfo(this.actions.rebaseEdit),
       false
     );
   }
 
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
+  // private but used in test
+  handleHideBackgroundContent() {
+    assertIsDefined(this.mainContent, 'mainContent');
+    this.mainContent.classList.add('overlayOpen');
   }
 
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
+  // private but used in test
+  handleShowBackgroundContent() {
+    assertIsDefined(this.mainContent, 'mainContent');
+    this.mainContent.classList.remove('overlayOpen');
   }
 
   /**
    * Merge sources of change actions into a single ordered array of action
    * values.
    */
-  _computeAllActions(
-    changeActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
+  private computeAllActions(
+    changeActions: ActionNameToActionInfoMap,
+    revisionActions: ActionNameToActionInfoMap,
+    primariesActions: PrimaryActionKey[],
+    additionalActions: UIActionInfo[],
     change?: ChangeInfo
   ): UIActionInfo[] {
     // Polymer 2: check for undefined
     if (
       [
-        changeActionsRecord,
-        revisionActionsRecord,
-        primariesRecord,
-        additionalActionsRecord,
+        changeActions,
+        revisionActions,
+        primariesActions,
+        additionalActions,
         change,
       ].includes(undefined)
     ) {
       return [];
     }
 
-    const revisionActionValues = this._getActionValues(
-      revisionActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const revisionActionValues = this.getActionValues(
+      revisionActions,
+      primariesActions,
+      additionalActions,
       ActionType.REVISION
     );
-    const changeActionValues = this._getActionValues(
-      changeActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const changeActionValues = this.getActionValues(
+      changeActions,
+      primariesActions,
+      additionalActions,
       ActionType.CHANGE
     );
-    const quickApprove = this._getQuickApproveAction();
+    const quickApprove = this.getQuickApproveAction();
     if (quickApprove) {
       changeActionValues.unshift(quickApprove);
     }
 
     return revisionActionValues
       .concat(changeActionValues)
-      .sort((a, b) => this._actionComparator(a, b))
+      .sort((a, b) => this.actionComparator(a, b))
       .map(action => {
         if (ACTIONS_WITH_ICONS.has(action.__key)) {
           action.icon = action.__key;
         }
         return action;
       })
-      .filter(action => !this._shouldSkipAction(action));
+      .filter(action => !this.shouldSkipAction(action));
   }
 
-  _getActionPriority(action: UIActionInfo) {
+  private getActionPriority(action: UIActionInfo) {
     if (action.__type && action.__key) {
-      const overrideAction = this._actionPriorityOverrides.find(
+      const overrideAction = this.actionPriorityOverrides.find(
         i => i.type === action.__type && i.key === action.__key
       );
 
@@ -1952,10 +2309,12 @@
 
   /**
    * Sort comparator to define the order of change actions.
+   *
+   * private but used in test
    */
-  _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+  actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
     const priorityDelta =
-      this._getActionPriority(actionA) - this._getActionPriority(actionB);
+      this.getActionPriority(actionA) - this.getActionPriority(actionB);
     // Sort by the button label if same priority.
     if (priorityDelta === 0) {
       return actionA.label > actionB.label ? 1 : -1;
@@ -1964,40 +2323,38 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo) {
+  private shouldSkipAction(action: UIActionInfo) {
     return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
-  _computeTopLevelActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+  computeTopLevelActions(
+    action: UIActionInfo[],
+    hiddenActions: string[],
     editMode: boolean
   ): UIActionInfo[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base.filter(a => {
+    return action.filter(a => {
       if (hiddenActions.includes(a.__key)) return false;
       if (editMode) return EDIT_ACTIONS.has(a.__key);
-      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
+      return this.getActionOverflowIndex(a.__type, a.__key) === -1;
     });
   }
 
-  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(
+  private filterPrimaryActions(topLevelActions?: UIActionInfo[]) {
+    this.topLevelPrimaryActions = topLevelActions!.filter(
       action => action.__primary
     );
-    this._topLevelSecondaryActions = _topLevelActions.filter(
+    this.topLevelSecondaryActions = topLevelActions!.filter(
       action => !action.__primary
     );
   }
 
-  _computeMenuActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+  private computeMenuActions(
+    action: UIActionInfo[],
+    hiddenActions: string[]
   ): MenuAction[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base
+    return action
       .filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        const overflow = this.getActionOverflowIndex(a.__type, a.__key) !== -1;
         return overflow && !hiddenActions.includes(a.__key);
       })
       .map(action => {
@@ -2014,15 +2371,6 @@
       });
   }
 
-  _computeRebaseOnCurrent(
-    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
-  ) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
   /**
    * Occasionally, a change created by a change action is not yet known to the
    * API for a brief time. Wait for the given change number to be recognized.
@@ -2030,8 +2378,9 @@
    * Returns a promise that resolves with true if a request is recognized, or
    * false if the change was never recognized after all attempts.
    *
+   * private but used in test
    */
-  _waitForChangeReachable(changeNum: NumericChangeId) {
+  waitForChangeReachable(changeNum: NumericChangeId) {
     let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
@@ -2057,24 +2406,19 @@
     });
   }
 
-  _handleEditTap() {
+  private handleEditTap() {
     this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
   }
 
-  _handleStopEditTap() {
+  private handleStopEditTap() {
     this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
   }
-
-  _computeHasTooltip(title?: string) {
-    return !!title;
-  }
-
-  _computeHasIcon(action: UIActionInfo) {
-    return action.icon ? '' : 'hidden';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-actions': GrChangeActions;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
deleted file mode 100644
index 17ca7cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      font-family: var(--font-family);
-    }
-    #actionLoadingMessage,
-    #mainContent,
-    section {
-      display: flex;
-    }
-    #actionLoadingMessage,
-    gr-button,
-    gr-dropdown {
-      /* px because don't have the same font size */
-      margin-left: 8px;
-    }
-    gr-button {
-      display: block;
-    }
-    #actionLoadingMessage {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-    }
-    #confirmSubmitDialog .changeSubject {
-      margin: var(--spacing-l);
-      text-align: center;
-    }
-    iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-xs);
-    }
-    #moreActions iron-icon {
-      margin: 0;
-    }
-    #moreMessage,
-    .hidden {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      #mainContent {
-        flex-wrap: wrap;
-      }
-      gr-button {
-        --gr-button-padding: var(--spacing-m);
-        white-space: nowrap;
-      }
-      gr-button,
-      gr-dropdown {
-        margin: 0;
-      }
-      #actionLoadingMessage {
-        margin: var(--spacing-m);
-        text-align: center;
-      }
-      #moreMessage {
-        display: inline;
-      }
-    }
-  </style>
-  <div id="mainContent">
-    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
-      [[_actionLoadingMessage]]</span
-    >
-    <section
-      id="primaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <section
-      id="secondaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template
-        is="dom-repeat"
-        items="[[_topLevelSecondaryActions]]"
-        as="action"
-      >
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <gr-button hidden$="[[!_loading]]" disabled=""
-      >Loading actions...</gr-button
-    >
-    <gr-dropdown
-      id="moreActions"
-      link=""
-      vertical-offset="32"
-      horizontal-align="right"
-      on-tap-item="_handleOverflowItemTap"
-      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-      disabled-ids="[[_disabledMenuActions]]"
-      items="[[_menuActions]]"
-    >
-      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-      </iron-icon>
-      <span id="moreMessage">More</span>
-    </gr-dropdown>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-rebase-dialog
-      id="confirmRebase"
-      class="confirmDialog"
-      change-number="[[change._number]]"
-      on-confirm="_handleRebaseConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      branch="[[change.branch]]"
-      has-parent="[[hasParent]]"
-      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
-      hidden=""
-    ></gr-confirm-rebase-dialog>
-    <gr-confirm-cherrypick-dialog
-      id="confirmCherrypick"
-      class="confirmDialog"
-      change-status="[[changeStatus]]"
-      commit-message="[[commitMessage]]"
-      commit-num="[[commitNum]]"
-      on-confirm="_handleCherrypickConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-cherrypick-dialog>
-    <gr-confirm-cherrypick-conflict-dialog
-      id="confirmCherrypickConflict"
-      class="confirmDialog"
-      on-confirm="_handleCherrypickConflictConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-cherrypick-conflict-dialog>
-    <gr-confirm-move-dialog
-      id="confirmMove"
-      class="confirmDialog"
-      on-confirm="_handleMoveConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-move-dialog>
-    <gr-confirm-revert-dialog
-      id="confirmRevertDialog"
-      class="confirmDialog"
-      on-confirm="_handleRevertDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-dialog>
-    <gr-confirm-abandon-dialog
-      id="confirmAbandonDialog"
-      class="confirmDialog"
-      on-confirm="_handleAbandonDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-abandon-dialog>
-    <gr-confirm-submit-dialog
-      id="confirmSubmitDialog"
-      class="confirmDialog"
-      action="[[_revisionSubmitAction]]"
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleSubmitConfirm"
-      hidden=""
-    ></gr-confirm-submit-dialog>
-    <gr-dialog
-      id="createFollowUpDialog"
-      class="confirmDialog"
-      confirm-label="Create"
-      on-confirm="_handleCreateFollowUpChange"
-      on-cancel="_handleCloseCreateFollowUpChange"
-    >
-      <div class="header" slot="header">Create Follow-Up Change</div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createFollowUpChange"
-          branch="[[change.branch]]"
-          base-change="[[change.id]]"
-          repo-name="[[change.project]]"
-          private-by-default="[[privateByDefault]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteConfirm"
-    >
-      <div class="header" slot="header">Delete Change</div>
-      <div class="main" slot="main">
-        Do you really want to delete the change?
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteEditDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteEditConfirm"
-    >
-      <div class="header" slot="header">Delete Change Edit</div>
-      <div class="main" slot="main">Do you really want to delete the edit?</div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 50ab91c..3d97769 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -54,23 +54,28 @@
   TopicName,
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {getAppContext} from '../../../services/app-context';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
   let element: GrChangeActions;
 
   suite('basic tests', () => {
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve({
           cherrypick: {
@@ -127,7 +132,9 @@
         .stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
       element.change = createChangeViewChange();
       element.changeNum = 42 as NumericChangeId;
       element.latestPatchNum = 2 as PatchSetNum;
@@ -144,61 +151,41 @@
       };
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
 
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
     });
 
     test('show-revision-actions event should fire', async () => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      const spy = sinon.spy(element, 'sendShowRevisionActions');
       element.reload();
-      await flush();
+      await element.updateComplete;
       assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
       // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions!.length, 1);
-      assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+      assert.equal(element.topLevelPrimaryActions!.length, 1);
+      assert.equal(element.topLevelPrimaryActions![0].label, 'Submit');
       assert.equal(
-        element._topLevelSecondaryActions!.length,
-        element._topLevelActions!.length - 1
+        element.topLevelSecondaryActions!.length,
+        element.topLevelActions!.length - 1
       );
     });
 
     test('revert submission action is skipped', () => {
       assert.equal(
-        element._allActionValues.filter(action => action.__key === 'submit')
+        element.allActionValues.filter(action => action.__key === 'submit')
           .length,
         1
       );
       assert.equal(
-        element._allActionValues.filter(
+        element.allActionValues.filter(
           action => action.__key === 'revert_submission'
         ).length,
         0
       );
     });
 
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(
-        element._shouldHideActions(
-          {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
-            UIActionInfo[],
-            UIActionInfo[]
-          >,
-          false
-        )
-      );
-      assert.isFalse(
-        element._shouldHideActions(
-          {
-            base: [{__key: 'test'}] as UIActionInfo[],
-          } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-          false
-        )
-      );
-    });
-
     test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
@@ -207,7 +194,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(
           element.changeNum,
@@ -229,7 +216,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(element.changeNum, undefined, '/plugin~action')
       );
@@ -266,7 +253,7 @@
     });
 
     test('hide revision action', async () => {
-      await flush();
+      await element.updateComplete;
       let buttonEl: Element | undefined = queryAndAssert(
         element,
         '[data-action-key="submit"]'
@@ -277,14 +264,8 @@
         element.RevisionActions.SUBMIT,
         true
       );
-      assert.lengthOf(element._hiddenActions, 1);
-      element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
-        true
-      );
-      assert.lengthOf(element._hiddenActions, 1);
-      await flush();
+      assert.lengthOf(element.hiddenActions, 1);
+      await element.updateComplete;
       buttonEl = query(element, '[data-action-key="submit"]');
       assert.isNotOk(buttonEl);
 
@@ -293,31 +274,35 @@
         element.RevisionActions.SUBMIT,
         false
       );
-      await flush();
+      await element.updateComplete;
       buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
       assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
     test('buttons exist', async () => {
-      element._loading = false;
-      await flush();
+      element.loading = false;
+      await element.updateComplete;
       const buttonEls = queryAll(element, 'gr-button');
-      const menuItems = element.$.moreActions.items;
+      const menuItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items;
 
       // Total button number is one greater than the number of total actions
       // due to the existence of the overflow menu trigger.
       assert.equal(
         buttonEls.length + menuItems!.length,
-        element._allActionValues.length + 1
+        element.allActionValues.length + 1
       );
       assert.isFalse(element.hidden);
     });
 
     test('delete buttons have explicit labels', async () => {
-      await flush();
-      const deleteItems = element.$.moreActions.items!.filter(item =>
-        item.id!.startsWith('delete')
-      );
+      await element.updateComplete;
+      const deleteItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items!.filter(item => item.id!.startsWith('delete'));
       assert.equal(deleteItems.length, 1);
       assert.equal(deleteItems[0].name, 'Delete change');
     });
@@ -335,10 +320,10 @@
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+      assert.deepEqual(element.getRevision(change, 2 as PatchSetNum), revObj);
     });
 
-    test('_actionComparator sort order', () => {
+    test('actionComparator sort order', () => {
       const actions = [
         {label: '123', __type: ActionType.CHANGE, __key: 'review'},
         {label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
@@ -354,16 +339,18 @@
 
       const result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator.bind(element));
+      result.sort(element.actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
     test('submit change', async () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
+      const showSpy = sinon.spy(element, 'showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -373,25 +360,36 @@
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
-      const submitButton = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
 
-      await flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+      await element.updateComplete;
+      assert.isTrue(
+        showSpy.calledWith(
+          queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+        )
+      );
     });
 
     test('submit change, tap on icon', async () => {
       const submitted = mockPromise();
       sinon
-        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .stub(
+          queryAndAssert<GrConfirmSubmitDialog>(
+            element,
+            '#confirmSubmitDialog'
+          ),
+          'resetFocus'
+        )
         .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -401,18 +399,17 @@
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
-      const submitIcon = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"] iron-icon'
-      );
-      tap(submitIcon);
+      ).click();
       await submitted;
     });
 
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
+    test('handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(true);
+      element.handleSubmitConfirm();
       assert.isTrue(fireStub.calledOnce);
       assert.deepEqual(fireStub.lastCall.args, [
         '/submit',
@@ -421,77 +418,66 @@
       ]);
     });
 
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
+    test('handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(false);
+      element.handleSubmitConfirm();
       assert.isFalse(fireStub.called);
     });
 
     test('submit change with plugin hook', async () => {
-      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      await flush();
-      const submitButton = queryAndAssert(
+      sinon.stub(element, 'canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, 'fireAction');
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
       assert.equal(fireActionStub.callCount, 0);
     });
 
-    test('chain state', () => {
+    test('chain state', async () => {
       assert.equal(element._hasKnownChainState, false);
       element.hasParent = true;
+      await element.updateComplete;
       assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
     });
 
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
+    test('calculateDisabled', () => {
       const action = {
         __key: 'rebase',
         enabled: true,
         __type: ActionType.CHANGE,
         label: 'l',
       };
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        true
-      );
+      element._hasKnownChainState = false;
+      assert.equal(element.calculateDisabled(action), true);
 
       action.__key = 'delete';
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
 
       action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      element._hasKnownChainState = true;
+      assert.equal(element.calculateDisabled(action), false);
 
       action.enabled = false;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
     });
 
     test('rebase change', async () => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fireActionStub = sinon.stub(element, 'fireAction');
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
-      );
-      tap(rebaseButton);
+      ).click();
       const rebaseAction = {
         __key: 'rebase',
         __type: 'revision',
@@ -502,7 +488,7 @@
         title: 'Rebase onto tip of branch or parent change',
       };
       assert.isTrue(fetchChangesStub.called);
-      element._handleRebaseConfirm(
+      element.handleRebaseConfirm(
         new CustomEvent('', {detail: {base: '1234'}})
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
@@ -515,87 +501,108 @@
 
     test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
-      element._handleResponse(
+      await element.handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      await flush();
       assert.isTrue(eventStub.called);
       assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
     test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      const rebaseButton = queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
       );
-      tap(rebaseButton);
+      rebaseButton.click();
+      await element.updateComplete;
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      await flush();
-      element.$.confirmRebase.dispatchEvent(
+      await element.updateComplete;
+      queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        '#confirmRebase'
+      ).dispatchEvent(
         new CustomEvent('cancel', {
           composed: true,
           bubbles: true,
         })
       );
-      tap(rebaseButton);
+      rebaseButton.click();
       assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
+      ).click();
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
       );
-      tap(rebaseButton);
-      await flush();
-      assert.isFalse(element.$.confirmRebase.hidden);
       stubRestApi('getChanges').returns(Promise.resolve([]));
-      element._handleCherrypickTap();
-      await flush();
-      assert.isTrue(element.$.confirmRebase.hidden);
-      assert.isFalse(element.$.confirmCherrypick.hidden);
+      element.handleCherrypickTap();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
+      );
+      assert.isFalse(
+        queryAndAssert<GrConfirmCherrypickDialog>(element, '#confirmCherrypick')
+          .hidden
+      );
     });
 
     test('fullscreen-overlay-opened hides content', () => {
-      const spy = sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
+      const spy = sinon.spy(element, 'handleHideBackgroundContent');
+      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
         new CustomEvent('fullscreen-overlay-opened', {
           composed: true,
           bubbles: true,
         })
       );
       assert.isTrue(spy.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.isTrue(
+        queryAndAssert<Element>(element, '#mainContent').classList.contains(
+          'overlayOpen'
+        )
+      );
     });
 
     test('fullscreen-overlay-closed shows content', () => {
-      const spy = sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
+      const spy = sinon.spy(element, 'handleShowBackgroundContent');
+      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
         new CustomEvent('fullscreen-overlay-closed', {
           composed: true,
           bubbles: true,
         })
       );
       assert.isTrue(spy.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.isFalse(
+        queryAndAssert<Element>(element, '#mainContent').classList.contains(
+          'overlayOpen'
+        )
+      );
     });
 
-    test('_setReviewOnRevert', () => {
+    test('setReviewOnRevert', () => {
       const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
       const changeId = 1234 as NumericChangeId;
       sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
         Promise.resolve(new Response())
       );
-      const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+      const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
         undefined | Response
       >;
       return setReviewOnRevert.then((_res: Response | undefined) => {
@@ -607,14 +614,14 @@
 
     suite('change edits', () => {
       test('disableEdit', async () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.editMode = false;
+        element.editBasedOnCurrentPatchSet = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('disableEdit', true);
-        await flush();
+        element.disableEdit = true;
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -630,28 +637,30 @@
       });
 
       test('shows confirm dialog for delete edit', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
 
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteEditDialog'),
-            'gr-button[primary]'
-          )
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
         );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
       test('all cached change edits get deleted on delete edit', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
 
         const storage = getAppContext().storageService;
         storage.setEditableContentItem(
@@ -679,31 +688,31 @@
         const eraseEditableContentItemsForChangeEditSpy = spyStorage(
           'eraseEditableContentItemsForChangeEdit'
         );
-        sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteEditDialog'),
-            'gr-button[primary]'
-          )
+        sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
         );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(eraseEditableContentItemsForChangeEditSpy.called);
         assert.isNotOk(storage.getEditableContentItem('c42_psedit_index.php')!);
         assert.isNotOk(storage.getEditableContentItem('c42_ps2_index.php')!);
       });
 
       test('edit patchset is loaded, needs rebase', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -715,15 +724,15 @@
       });
 
       test('edit patchset is loaded, does not need rebase', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        await flush();
+        await element.updateComplete;
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -735,14 +744,14 @@
       });
 
       test('edit mode is loaded, no edit patchset', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -758,14 +767,14 @@
       });
 
       test('normal patch set', async () => {
-        element.set('loggedIn', true);
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = false;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -781,17 +790,17 @@
       });
 
       test('edit action', async () => {
-        element.set('loggedIn', true);
+        element.loggedIn = true;
         const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
           editTapped.resolve();
         });
-        element.set('editMode', true);
+        element.editMode = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -799,34 +808,33 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('editMode', false);
-        await flush();
+        element.editMode = false;
+        await element.updateComplete;
 
-        const editButton = queryAndAssert(
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="edit"]'
-        );
-        tap(editButton);
+        ).click();
         await editTapped;
       });
     });
 
     test('edit action not shown for logged out user', async () => {
-      element.set('loggedIn', false);
-      element.set('editMode', false);
-      element.set('editPatchsetLoaded', false);
+      element.loggedIn = false;
+      element.editMode = false;
+      element.editPatchsetLoaded = false;
       element.change = {
         ...createChangeViewChange(),
         status: ChangeStatus.NEW,
       };
-      await flush();
+      await element.updateComplete;
 
       assert.isNotOk(
         query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -841,12 +849,12 @@
       let fireActionStub: sinon.SinonStub;
 
       setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
       });
 
       test('works', async () => {
-        element._handleCherrypickTap();
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -857,24 +865,39 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
-        element._handleCherrypickConfirm();
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0); // Still needs a message.
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
         await element.updateComplete;
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
         await element.updateComplete;
 
         const autogrowEl = queryAndAssert<IronAutogrowTextareaElement>(
-          element.$.confirmCherrypick,
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ),
           '#messageInput'
         );
         assert.equal(autogrowEl.value, 'foo message');
@@ -893,7 +916,7 @@
       });
 
       test('cherry pick even with conflicts', async () => {
-        element._handleCherrypickTap();
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -904,15 +927,27 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
         await element.updateComplete;
 
-        element._handleCherrypickConflictConfirm();
+        element.handleCherrypickConflictConfirm();
         await element.updateComplete;
 
         assert.deepEqual(fireActionStub.lastCall.args, [
@@ -930,10 +965,19 @@
 
       test('branch name cleared when re-open cherrypick', () => {
         const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+        element.handleCherrypickTap();
+        assert.equal(
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ).branch,
+          emptyBranchName
+        );
       });
 
       suite('cherry pick topics', () => {
@@ -957,20 +1001,28 @@
         ];
         setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          await flush();
-          const radioButtons = queryAll(
-            element.$.confirmCherrypick,
+          element.handleCherrypickTap();
+          await element.updateComplete;
+          const confirmCherrypick = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
+          const radioButtons = queryAll<HTMLInputElement>(
+            confirmCherrypick,
             "input[name='cherryPickOptions']"
           );
           assert.equal(radioButtons.length, 2);
-          tap(radioButtons[1]);
-          await flush();
+          radioButtons[1].click();
+          await element.updateComplete;
         });
 
         test('cherry pick topic dialog is rendered', async () => {
-          const dialog = element.$.confirmCherrypick;
-          await flush();
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
           const changesTable = queryAndAssert(dialog, 'table');
           const headers = Array.from(changesTable.querySelectorAll('th'));
           const expectedHeadings = [
@@ -1006,7 +1058,10 @@
         });
 
         test('changes with duplicate project show an error', async () => {
-          const dialog = element.$.confirmCherrypick;
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
           const error = queryAndAssert<HTMLSpanElement>(
             dialog,
             '.error-message'
@@ -1028,7 +1083,7 @@
               project: 'A' as RepoName,
             },
           ]);
-          await flush();
+          await element.updateComplete;
           assert.equal(
             error.innerText,
             'Two changes cannot be of the same' + ' project'
@@ -1040,8 +1095,8 @@
     suite('move change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
         element.actions = {
           move: {
@@ -1051,25 +1106,31 @@
             enabled: true,
           },
         };
+        await element.updateComplete;
       });
 
       test('works', () => {
-        element._handleMoveTap();
+        element.handleMoveTap();
 
-        element._handleMoveConfirm();
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmMove.branch = 'master' as BranchName;
-        element._handleMoveConfirm();
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 1);
       });
 
       test('branch name cleared when re-open move', () => {
         const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
 
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+        element.handleMoveTap();
+        assert.equal(
+          queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch,
+          emptyBranchName
+        );
       });
     });
 
@@ -1084,20 +1145,24 @@
           key
         );
         element.removeActionButton(key);
-        await flush();
+        await element.updateComplete;
         assert.notOk(query(element, '[data-action-key="' + key + '"]'));
         keyTapped.resolve();
       });
-      await flush();
-      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await element.updateComplete;
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
+        element,
+        '[data-action-key="' + key + '"]'
+      ).click();
       await keyTapped;
     });
 
-    test('_setLoadingOnButtonWithKey top-level', () => {
+    test('setLoadingOnButtonWithKey top-level', () => {
       const key = 'rebase';
       const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      assert.equal(element.actionLoadingMessage, 'Rebasing...');
 
       const button = queryAndAssert<GrButton>(
         element,
@@ -1112,29 +1177,29 @@
 
       assert.isFalse(button.hasAttribute('loading'));
       assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
+      assert.isNotOk(element.actionLoadingMessage);
     });
 
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
+    test('setLoadingOnButtonWithKey overflow menu', () => {
       const key = 'cherrypick';
       const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
+      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element.disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
 
       cleanup();
 
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+      assert.notOk(element.actionLoadingMessage);
+      assert.notInclude(element.disabledMenuActions, 'cherrypick');
     });
 
     suite('abandon change', () => {
       let alertStub: sinon.SinonStub;
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         alertStub = sinon.stub(window, 'alert');
         element.actions = {
           abandon: {
@@ -1144,43 +1209,63 @@
             enabled: true,
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
       });
 
       test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        await flush();
-        const abandonButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = newAbandonMsg;
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          newAbandonMsg
+        );
       });
 
       test('abandon change with no message', async () => {
-        await flush();
-        const abandonButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, '');
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          ''
+        );
       });
 
       test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'original message';
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(restoreButton);
+        ).click();
 
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'foo message';
+        element.handleAbandonDialogConfirm();
         assert.notOk(alertStub.called);
 
         const action = {
@@ -1206,8 +1291,8 @@
     suite('revert change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         element.commitMessage = 'random commit message';
         element.change!.current_revision = 'abcdef' as CommitId;
         element.actions = {
@@ -1218,13 +1303,21 @@
             enabled: true,
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
       });
 
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
-          .stub(element.$.confirmRevertDialog, 'modifyRevertMsg')
+          .stub(
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
+            'modifyRevertMsg'
+          )
           .callsFake(() => newRevertMsg);
         element.change = {
           ...createChangeViewChange(),
@@ -1248,23 +1341,29 @@
         );
         sinon
           .stub(
-            element.$.confirmRevertDialog,
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
             'populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        await flush();
-        const revertButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="revert"]'
+        ).click();
+        await element.updateComplete;
+        assert.equal(
+          queryAndAssert<GrConfirmRevertDialog>(element, '#confirmRevertDialog')
+            .message,
+          newRevertMsg
         );
-        tap(revertButton);
-        await flush();
-        assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
         let getChangesStub: sinon.SinonStub;
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199 0' as ChangeSubmissionId,
@@ -1286,17 +1385,21 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('confirm revert dialog shows both options', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          await flush();
+          ).click();
+          await element.updateComplete;
           assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
+          );
+          await element.updateComplete;
           const revertSingleChangeLabel = queryAndAssert<HTMLLabelElement>(
             confirmRevertDialog,
             '.revertSingleChange'
@@ -1324,12 +1427,12 @@
             '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
             '\n';
           assert.equal(confirmRevertDialog.message, expectedMsg);
-          const radioInputs = queryAll(
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
-          tap(radioInputs[0]);
-          await flush();
+          radioInputs[0].click();
+          await element.updateComplete;
           expectedMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
@@ -1338,33 +1441,43 @@
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
+          ).click();
+          await element.updateComplete;
           assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('message modification is retained on switching', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
-          await flush();
-          const radioInputs = queryAll(
+          await element.updateComplete;
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
@@ -1387,21 +1500,23 @@
           const newRevertMsg = revertSubmissionMsg + 'random';
           const newSingleChangeMsg = singleChangeMsg + 'random';
           confirmRevertDialog.message = newRevertMsg;
-          tap(radioInputs[0]);
-          await flush();
+          await element.updateComplete;
+          radioInputs[0].click();
+          await element.updateComplete;
           assert.equal(confirmRevertDialog.message, singleChangeMsg);
           confirmRevertDialog.message = newSingleChangeMsg;
-          tap(radioInputs[1]);
-          await flush();
+          await element.updateComplete;
+          radioInputs[1].click();
+          await element.updateComplete;
           assert.equal(confirmRevertDialog.message, newRevertMsg);
-          tap(radioInputs[0]);
-          await flush();
+          radioInputs[0].click();
+          await element.updateComplete;
           assert.equal(confirmRevertDialog.message, newSingleChangeMsg);
         });
       });
 
       suite('revert single change', () => {
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199' as ChangeSubmissionId,
@@ -1417,35 +1532,45 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
+          ).click();
+          await element.updateComplete;
           assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('confirm revert dialog shows no radio button', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          tap(revertButton);
-          await flush();
-          const confirmRevertDialog = element.$.confirmRevertDialog;
           const radioInputs = queryAll(
             confirmRevertDialog,
             'input[name="revertOptions"]'
@@ -1458,12 +1583,18 @@
           assert.equal(confirmRevertDialog.message, msg);
           let editedMsg = msg + 'hello';
           confirmRevertDialog.message += 'hello';
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          const confirmButton = queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
           );
-          tap(confirmButton);
-          await flush();
+          confirmButton.click();
+          await element.updateComplete;
           // Contains generic template reason so doesn't submit
           assert.isFalse(fireActionStub.called);
           confirmRevertDialog.message = confirmRevertDialog.message.replace(
@@ -1471,8 +1602,8 @@
             ''
           );
           editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
-          tap(confirmButton);
-          await flush();
+          confirmButton.click();
+          await element.updateComplete;
           assert.equal(fireActionStub.getCall(0).args[0], '/revert');
           assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
           assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
@@ -1481,7 +1612,7 @@
     });
 
     suite('mark change private', () => {
-      setup(() => {
+      setup(async () => {
         const privateAction = {
           __key: 'private',
           __type: 'change',
@@ -1501,34 +1632,41 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
       test('private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
       });
     });
 
     suite('unmark private change', () => {
-      setup(() => {
+      setup(async () => {
         const unmarkPrivateAction = {
           __key: 'private.delete',
           __type: 'change',
@@ -1548,28 +1686,35 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
       test('unmark the private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private.delete"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
       });
     });
@@ -1578,8 +1723,8 @@
       let fireActionStub: sinon.SinonStub;
       let deleteAction: ActionInfo;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
@@ -1593,37 +1738,34 @@
         element.actions = {
           '/': deleteAction,
         };
+        await element.updateComplete;
       });
 
       test('does not delete on action', () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(fireActionStub.called);
       });
 
       test('shows confirm dialog', async () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(
           queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button[primary]'
-          )
-        );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
       test('hides delete confirm on cancel', async () => {
-        element._handleDeleteTap();
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button:not([primary])'
-          )
-        );
-        await flush();
+        element.handleDeleteTap();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button:not([primary])'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(
           queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
@@ -1649,7 +1791,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
       });
 
       test('added when can approve', () => {
@@ -1670,7 +1812,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        await flush();
+        await element.updateComplete;
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1680,8 +1822,10 @@
       });
 
       test('is first in list of secondary actions', () => {
-        const approveButton =
-          element.$.secondaryActions.querySelector('gr-button');
+        const approveButton = queryAndAssert<HTMLElement>(
+          element,
+          '#secondaryActions'
+        ).querySelector('gr-button');
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
@@ -1691,7 +1835,7 @@
           status: ChangeStatus.MERGED,
         };
 
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1713,7 +1857,7 @@
             foo: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1732,7 +1876,7 @@
             bar: [],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1758,7 +1902,7 @@
             'Code-Review': ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1767,9 +1911,12 @@
       });
 
       test('approves when tapped', async () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        await flush();
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        queryAndAssert<GrButton>(
+          element,
+          "gr-button[data-action-key='review']"
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
@@ -1789,7 +1936,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1819,7 +1966,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1845,7 +1992,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1871,7 +2018,7 @@
             bar: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1897,7 +2044,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1923,8 +2070,8 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
-        const approveButton = queryAndAssert(
+        await element.updateComplete;
+        const approveButton = queryAndAssert<GrButton>(
           element,
           "gr-button[data-action-key='review']"
         );
@@ -1956,7 +2103,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1983,7 +2130,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1996,8 +2143,8 @@
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      await flush();
+      element.handleDownloadTap();
+      await element.updateComplete;
 
       assert.isTrue(handler.called);
     });
@@ -2010,26 +2157,26 @@
       assert.isFalse(reloadStub.called);
     });
 
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    test('toSentenceCase', () => {
+      assert.equal(element.toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element.toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element.toSentenceCase('b'), 'B');
+      assert.equal(element.toSentenceCase(''), '');
+      assert.equal(element.toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
     });
 
     suite('setActionOverflow', () => {
       test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
-          element.$.moreActions.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
-          element.$.moreActions.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
       });
@@ -2037,15 +2184,15 @@
       test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        await flush();
+        await element.updateComplete;
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
-          element.$.moreActions.items![3].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![2].id,
           'submit-revision'
         );
       });
 
-      suite('_waitForChangeReachable', () => {
+      suite('waitForChangeReachable', () => {
         let clock: SinonFakeTimers;
         setup(() => {
           clock = sinon.useFakeTimers();
@@ -2066,13 +2213,13 @@
         const tickAndFlush = async (repetitions: number) => {
           for (let i = 1; i <= repetitions; i++) {
             clock.tick(1000);
-            await flush();
+            await element.updateComplete;
           }
         };
 
         test('succeed', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(5);
@@ -2082,7 +2229,7 @@
 
         test('fail', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(6);
@@ -2092,14 +2239,14 @@
       });
     });
 
-    suite('_send', () => {
+    suite('send', () => {
       let cleanup: sinon.SinonStub;
       const payload = {foo: 'bar'};
       let onShowError: sinon.SinonStub;
       let onShowAlert: sinon.SinonStub;
       let getResponseObjectStub: sinon.SinonStub;
 
-      setup(() => {
+      setup(async () => {
         cleanup = sinon.stub();
         element.changeNum = 42 as NumericChangeId;
         element.latestPatchNum = 12 as PatchSetNum;
@@ -2109,6 +2256,7 @@
           messages: createChangeMessages(1),
         };
         element.change._number = 42 as NumericChangeId;
+        await element.updateComplete;
 
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
@@ -2135,7 +2283,7 @@
         });
 
         test('change action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2157,7 +2305,7 @@
         });
 
         suite('show revert submission dialog', () => {
-          setup(() => {
+          setup(async () => {
             element.change!.submission_id = '199' as ChangeSubmissionId;
             element.change!.current_revision = '2000' as CommitId;
             stubRestApi('getChanges').returns(
@@ -2176,6 +2324,7 @@
                 },
               ])
             );
+            await element.updateComplete;
           });
         });
 
@@ -2192,7 +2341,7 @@
           });
 
           test('revert submission single change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2200,7 +2349,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2224,7 +2373,7 @@
                 ],
               })
             );
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            showActionDialogStub = sinon.stub(element, 'showActionDialog');
             navigateToSearchQueryStub = sinon.stub(
               GerritNav,
               'navigateToSearchQuery'
@@ -2232,7 +2381,7 @@
           });
 
           test('revert submission multiple change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2240,7 +2389,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2254,7 +2403,7 @@
         });
 
         test('revision action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2285,7 +2434,7 @@
           const sendStub = stubRestApi('executeChangeAction');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2317,10 +2466,10 @@
               return Promise.resolve(undefined);
             }
           );
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+          const handleErrorStub = sinon.stub(element, 'handleResponseError');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2338,12 +2487,12 @@
       });
     });
 
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_handleChangeAction');
+    test('handleAction reports', () => {
+      sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'handleChangeAction');
 
       const reportStub = stubReporting('reportInteraction');
-      element._handleAction(ActionType.CHANGE, 'key');
+      element.handleAction(ActionType.CHANGE, 'key');
       assert.isTrue(reportStub.called);
       assert.equal(reportStub.lastCall.args[0], 'change-key');
     });
@@ -2354,7 +2503,7 @@
 
     let changeRevisionActions: ActionNameToActionInfoMap = {};
 
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve(changeRevisionActions)
       );
@@ -2364,7 +2513,9 @@
         .stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
       // getChangeRevisionActions is not called without
       // set the following properties
       element.change = createChangeViewChange();
@@ -2372,33 +2523,23 @@
       element.latestPatchNum = 2 as PatchSetNum;
 
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
     });
 
     test('confirmSubmitDialog and confirmRebase properties are changed', () => {
       changeRevisionActions = {};
       element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: HttpMethod.POST,
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      rebaseAction.enabled = false;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+      assert.strictEqual(
+        queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+          .action,
+        null
+      );
+      assert.strictEqual(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase')
+          .rebaseOnCurrent,
+        null
+      );
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index e8448b7..87caddf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -2635,6 +2635,12 @@
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
     return this.shortcuts.createTitle(shortcutName, section);
   }
+
+  _handleRevisionActionsChanged(
+    e: CustomEvent<{value: ActionNameToActionInfoMap}>
+  ) {
+    this._currentRevisionActions = e.detail.value;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 72021e5..5d4db56 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -363,7 +363,7 @@
             disable-edit="[[disableEdit]]"
             has-parent="[[hasParent]]"
             actions="[[_change.actions]]"
-            revision-actions="{{_currentRevisionActions}}"
+            revision-actions="[[_currentRevisionActions]]"
             account="[[_account]]"
             change-num="[[_changeNum]]"
             change-status="[[_change.status]]"
@@ -379,6 +379,7 @@
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
             on-included-tap="_handleOpenIncludedInDialog"
+            on-revision-actions-changed="_handleRevisionActionsChanged"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index f4e5835..eb6d071 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -277,8 +277,6 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly flagService = getAppContext().flagsService;
-
   private readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
@@ -494,9 +492,6 @@
       this.checksChanged([]);
     }
 
-    const experiment = KnownExperimentId.CHECK_RESULTS_IN_DIFFS;
-    if (!this.flagService.isEnabled(experiment)) return;
-
     const path = this.path;
     const patchNum = this.patchRange?.patchNum;
     if (!path || !patchNum || patchNum === EditPatchSetNum) return;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 8a01ef7..64548ac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -1,39 +1,24 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-endpoint-decorator_html';
+import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
   getPluginEndpoints,
   ModuleInfo,
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-export class GrEndpointDecorator extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEndpointDecorator extends LitElement {
   /**
    * If set, then this endpoint only invokes callbacks registered by the target
    * plugin. For example this is used for the `check-result-expanded` endpoint.
@@ -43,39 +28,51 @@
   @property({type: String})
   targetPlugin?: string;
 
+  /** Required. */
   @property({type: String})
-  name!: string;
+  name?: string;
 
-  @property({type: Object})
-  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
+  private readonly domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
-  @property({type: Object})
-  _initializedPlugins = new Map<string, boolean>();
-
-  /**
-   * This is the callback that the plugin endpoint manager should be calling
-   * when a new element is registered for this endpoint. It points to
-   * _initModule().
-   */
-  _endpointCallBack: (info: ModuleInfo) => void = () => {};
+  private readonly initializedPlugins = new Map<string, boolean>();
 
   private readonly reporting = getAppContext().reportingService;
 
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assertIsDefined(this.name);
+    getPluginEndpoints().onNewEndpoint(this.name, this.initModule);
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        assertIsDefined(this.name);
+        const modules = getPluginEndpoints().getDetails(this.name);
+        for (const module of modules) {
+          this.initModule(module);
+        }
+      });
+  }
+
   override disconnectedCallback() {
-    for (const [el, domHook] of this._domHooks) {
+    for (const [el, domHook] of this.domHooks) {
       domHook.handleInstanceDetached(el);
     }
-    getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+    assertIsDefined(this.name);
+    getPluginEndpoints().onDetachedEndpoint(this.name, this.initModule);
     super.disconnectedCallback();
   }
 
-  _initDecoration(
+  private initDecoration(
     name: string,
     plugin: PluginApi,
     slot?: string
   ): Promise<HTMLElement> {
     const el = document.createElement(name) as PluginElement;
-    return this._initProperties(
+    return this.initProperties(
       el,
       plugin,
       // The direct children are slotted into <slot>, so this is identical to
@@ -88,13 +85,16 @@
       if (slot && slotEl?.parentNode) {
         slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
       } else {
-        this._appendChild(el);
+        this.appendChild(el);
       }
       return el;
     });
   }
 
-  _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
+  private initReplacement(
+    name: string,
+    plugin: PluginApi
+  ): Promise<HTMLElement> {
     // The direct children are slotted into <slot>, so they are identical to
     // this.shadowRoot.querySelector('slot').assignedElements().
     const directChildren = [...this.childNodes];
@@ -104,16 +104,16 @@
       .filter(node => node.nodeName !== 'SLOT')
       .forEach(node => node.remove());
     const el = document.createElement(name);
-    return this._initProperties(el, plugin).then((el: HTMLElement) =>
-      this._appendChild(el)
+    return this.initProperties(el, plugin).then((el: HTMLElement) =>
+      this.appendChild(el)
     );
   }
 
-  _getEndpointParams() {
+  private getEndpointParams() {
     return Array.from(this.querySelectorAll('gr-endpoint-param'));
   }
 
-  _initProperties(
+  private initProperties(
     el: PluginElement,
     plugin: PluginApi,
     content?: Element | null
@@ -128,7 +128,7 @@
     if (content) {
       el.content = content as HTMLElement;
     }
-    const expectProperties = this._getEndpointParams().map(paramEl => {
+    const expectProperties = this.getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
       // TODO: this should be replaced by accessing the property directly
       const paramName = paramEl.getAttribute('name');
@@ -170,53 +170,40 @@
       });
   }
 
-  _appendChild(el: HTMLElement): HTMLElement {
-    if (!this.root) throw Error('plugin endpoint decorator missing root');
-    return this.root.appendChild(el);
-  }
-
-  _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+  private readonly initModule = ({
+    moduleName,
+    plugin,
+    type,
+    domHook,
+    slot,
+  }: ModuleInfo) => {
     const name = plugin.getPluginName() + '.' + moduleName;
     if (this.targetPlugin) {
       if (this.targetPlugin !== plugin.getPluginName()) return;
     }
-    if (this._initializedPlugins.get(name)) {
+    if (this.initializedPlugins.get(name)) {
       return;
     }
     let initPromise;
     switch (type) {
       case 'decorate':
-        initPromise = this._initDecoration(moduleName, plugin, slot);
+        initPromise = this.initDecoration(moduleName, plugin, slot);
         break;
       case 'replace':
-        initPromise = this._initReplacement(moduleName, plugin);
+        initPromise = this.initReplacement(moduleName, plugin);
         break;
     }
     if (!initPromise) {
       throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
     }
-    this._initializedPlugins.set(name, true);
+    this.initializedPlugins.set(name, true);
     initPromise.then(el => {
       if (domHook) {
         domHook.handleInstanceAttached(el);
-        this._domHooks.set(el, domHook);
+        this.domHooks.set(el, domHook);
       }
     });
-  }
-
-  override ready() {
-    super.ready();
-    if (!this.name) return;
-    this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
-    getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() =>
-        getPluginEndpoints()
-          .getDetails(this.name)
-          .forEach(this._initModule, this)
-      );
-  }
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index f5096ea..d7acc61 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -1,60 +1,75 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-endpoint-decorator';
 import '../gr-endpoint-param/gr-endpoint-param';
 import '../gr-endpoint-slot/gr-endpoint-slot';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {resetPlugins} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {
+  mockPromise,
+  queryAndAssert,
+  resetPlugins,
+} from '../../../test/test-utils';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrEndpointDecorator} from './gr-endpoint-decorator';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
 
-const basicFixture = fixtureFromTemplate(
-  html`<div>
-    <gr-endpoint-decorator name="first">
-      <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-      <p>
-        <span>test slot</span>
-        <gr-endpoint-slot name="test"></gr-endpoint-slot>
-      </p>
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="second">
-      <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="banana">
-      <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </div>`
-);
-
 suite('gr-endpoint-decorator', () => {
-  let container: GrEndpointDecorator;
+  let container: HTMLElement;
 
   let plugin: PluginApi;
   let decorationHook: any;
   let decorationHookWithSlot: any;
   let replacementHook: any;
+  let first: GrEndpointDecorator;
+  let second: GrEndpointDecorator;
+  let banana: GrEndpointDecorator;
 
   setup(async () => {
     resetPlugins();
-    container = basicFixture.instantiate() as GrEndpointDecorator;
+    container = await fixture(
+      html`<div>
+        <gr-endpoint-decorator name="first">
+          <gr-endpoint-param
+            name="first-param"
+            .value=${'barbar'}
+          ></gr-endpoint-param>
+          <p>
+            <span>test slot</span>
+            <gr-endpoint-slot name="test"></gr-endpoint-slot>
+          </p>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="second">
+          <gr-endpoint-param
+            name="second-param"
+            .value=${'foofoo'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="banana">
+          <gr-endpoint-param
+            name="banana-param"
+            .value=${'yes'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+    first = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="first"]'
+    );
+    second = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="second"]'
+    );
+    banana = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+
     window.Gerrit.install(
       p => {
         plugin = p;
@@ -64,18 +79,33 @@
     );
     // Decoration
     decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    const decorationHookPromise = mockPromise();
+    decorationHook.onAttached(() => decorationHookPromise.resolve());
+
+    // Decoration with slot
     decorationHookWithSlot = plugin.registerCustomComponent(
       'first',
       'some-module-2',
       {slot: 'test'}
     );
+    const decorationHookSlotPromise = mockPromise();
+    decorationHookWithSlot.onAttached(() =>
+      decorationHookSlotPromise.resolve()
+    );
+
     // Replacement
     replacementHook = plugin.registerCustomComponent('second', 'other-module', {
       replace: true,
     });
+    const replacementHookPromise = mockPromise();
+    replacementHook.onAttached(() => replacementHookPromise.resolve());
+
     // Mimic all plugins loaded.
     getPluginLoader().loadPlugins([]);
-    await flush();
+
+    await decorationHookPromise;
+    await decorationHookSlotPromise;
+    await replacementHookPromise;
   });
 
   teardown(() => {
@@ -89,17 +119,15 @@
     assert.equal(endpoints.length, 3);
   });
 
-  test('decoration', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="first"]'
-    ) as GrEndpointDecorator;
-    const modules = Array.from(element.root!.children).filter(
+  test('first decoration', () => {
+    const element = first;
+    const modules = Array.from(element.children).filter(
       element => element.nodeName === 'SOME-MODULE'
     );
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'barbar');
+    assert.equal((module as any)['first-param'], 'barbar');
     return decorationHook
       .getLastAttached()
       .then((element: any) => {
@@ -112,14 +140,12 @@
   });
 
   test('decoration with slot', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="first"]'
-    ) as GrEndpointDecorator;
+    const element = first;
     const modules = [...element.querySelectorAll('some-module-2')];
     assert.equal(modules.length, 1);
     const [module] = modules;
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'barbar');
+    assert.equal((module as any)['first-param'], 'barbar');
     return decorationHookWithSlot
       .getLastAttached()
       .then((element: any) => {
@@ -132,14 +158,12 @@
   });
 
   test('replacement', () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="second"]'
-    ) as GrEndpointDecorator;
-    const module = Array.from(element.root!.children).find(
+    const element = second;
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'OTHER-MODULE'
     );
     assert.isOk(module);
-    assert.equal((module as any)['someparam'], 'foofoo');
+    assert.equal((module as any)['second-param'], 'foofoo');
     return replacementHook
       .getLastAttached()
       .then((element: any) => {
@@ -152,73 +176,92 @@
   });
 
   test('late registration', async () => {
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const module = Array.from(element.root!.children).find(
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const element = banana;
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     assert.isOk(module);
   });
 
   test('two modules', async () => {
-    plugin.registerCustomComponent('banana', 'mod-one');
-    plugin.registerCustomComponent('banana', 'mod-two');
-    await flush();
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const module1 = Array.from(element.root!.children).find(
+    const bananaHook1 = plugin.registerCustomComponent('banana', 'mod-one');
+    const bananaHookPromise1 = mockPromise();
+    bananaHook1.onAttached(() => bananaHookPromise1.resolve());
+    await bananaHookPromise1;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'mod-two');
+    const bananaHookPromise2 = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise2.resolve());
+    await bananaHookPromise2;
+
+    const element = banana;
+    const module1 = Array.from(element.children).find(
       element => element.nodeName === 'MOD-ONE'
     );
     assert.isOk(module1);
-    const module2 = Array.from(element.root!.children).find(
+    const module2 = Array.from(element.children).find(
       element => element.nodeName === 'MOD-TWO'
     );
     assert.isOk(module2);
   });
 
   test('late param setup', async () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+    let element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
     param['value'] = undefined;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    let module = Array.from(element.root!.children).find(
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+
+    element = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+    let module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     // Module waits for param to be defined.
     assert.isNotOk(module);
     const value = {abc: 'def'};
     param.value = value;
+    await param.updateComplete;
+    await bananaHookPromise;
 
-    await flush();
-    module = Array.from(element.root!.children).find(
+    module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
     assert.isOk(module);
-    assert.strictEqual((module as any)['someParam'], value);
+    assert.strictEqual((module as any)['banana-param'], value);
   });
 
   test('param is bound', async () => {
-    const element = container.querySelector(
-      'gr-endpoint-decorator[name="banana"]'
-    ) as GrEndpointDecorator;
-    const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+    const element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
     const value1 = {abc: 'def'};
     const value2 = {def: 'abc'};
     param.value = value1;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const module = Array.from(element.root!.children).find(
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const module = Array.from(element.children).find(
       element => element.nodeName === 'NOOB-NOOB'
     );
-    assert.strictEqual((module as any)['someParam'], value1);
+    assert.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value1);
+
     param.value = value2;
-    assert.strictEqual((module as any)['someParam'], value2);
+    await param.updateComplete;
+    assert.strictEqual((module as any)['banana-param'], value2);
   });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index ee89c86..5a00c37 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -24,26 +13,18 @@
 }
 
 @customElement('gr-endpoint-param')
-export class GrEndpointParam extends PolymerElement {
-  @property({type: String, reflectToAttribute: true})
+export class GrEndpointParam extends LitElement {
+  @property({type: String, reflect: true})
   name = '';
 
-  @property({
-    type: Object,
-    notify: true,
-    observer: '_valueChanged',
-  })
+  @property({type: Object})
   value?: unknown;
 
-  _valueChanged(value: unknown) {
-    /* In polymer 2 the following change was made:
-    "Property change notifications (property-changed events) aren't fired when
-    the value changes as a result of a binding from the host"
-    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-    To workaround this problem, we fire the event from the observer.
-    In some cases this fire the event twice, but our code is
-    ready for it.
-    */
-    this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('value')) {
+      this.dispatchEvent(
+        new CustomEvent('value-changed', {detail: {value: this.value}})
+      );
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
index f15b046..d6d1866 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,7 +17,7 @@
  * the registered element should appear inside of the endpoint.
  */
 @customElement('gr-endpoint-slot')
-export class GrEndpointSlot extends PolymerElement {
+export class GrEndpointSlot extends LitElement {
   @property({type: String})
   name!: string;
 }
@@ -40,6 +29,6 @@
  * This should help catch errors when you assign an element without
  * name to GrEndpointSlot type.
  */
-export interface GrEndpointSlot extends PolymerElement {
+export interface GrEndpointSlot extends LitElement {
   name: string;
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index c8597bf..ca3f11d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -16,12 +16,6 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
@@ -39,10 +33,7 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
@@ -66,24 +57,16 @@
 } from '../../../constants/constants';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 import {windowLocationReload} from '../../../utils/dom-util';
-import {ValueChangedEvent} from '../../../types/events';
-
-const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'disable_keyboard_shortcuts',
-  'disable_token_highlighting',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -98,39 +81,8 @@
   LocalPrefsToPrefs,
 }
 
-export interface GrSettingsView {
-  $: {
-    accountInfo: GrAccountInfo;
-    watchedProjectsEditor: GrWatchedProjectsEditor;
-    groupList: GrGroupList;
-    identities: GrIdentities;
-    diffPrefs: GrDiffPreferences;
-    sshEditor: GrSshEditor;
-    gpgEditor: GrGpgEditor;
-    emailEditor: GrEmailEditor;
-    insertSignedOff: HTMLInputElement;
-    workInProgressByDefault: HTMLInputElement;
-    showSizeBarsInFileList: HTMLInputElement;
-    publishCommentsOnPush: HTMLInputElement;
-    disableKeyboardShortcuts: HTMLInputElement;
-    disableTokenHighlighting: HTMLInputElement;
-    relativeDateInChangeTable: HTMLInputElement;
-    changesPerPageSelect: HTMLInputElement;
-    dateTimeFormatSelect: HTMLInputElement;
-    timeFormatSelect: HTMLInputElement;
-    emailNotificationsSelect: HTMLInputElement;
-    emailFormatSelect: HTMLInputElement;
-    defaultBaseForMergesSelect: HTMLInputElement;
-    diffViewSelect: HTMLInputElement;
-  };
-}
-
 @customElement('gr-settings-view')
-export class GrSettingsView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -143,66 +95,106 @@
    * @event show-alert
    */
 
-  @property({type: Object})
-  prefs: PreferencesInput = {};
+  @query('#accountInfo', true) accountInfo!: GrAccountInfo;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  @query('#watchedProjectsEditor', true)
+  watchedProjectsEditor!: GrWatchedProjectsEditor;
 
-  @property({type: Boolean})
-  _accountInfoChanged = false;
+  @query('#groupList', true) groupList!: GrGroupList;
 
-  @property({type: Object})
-  _localPrefs: PreferencesInput = {};
+  @query('#identities', true) identities!: GrIdentities;
 
-  @property({type: Array})
-  _localChangeTableColumns: string[] = [];
+  @query('#diffPrefs') diffPrefs!: GrDiffPreferences;
 
-  @property({type: Boolean})
-  _loading = true;
+  @query('#sshEditor') sshEditor?: GrSshEditor;
 
-  @property({type: Boolean})
-  _changeTableChanged = false;
+  @query('#gpgEditor') gpgEditor?: GrGpgEditor;
 
-  @property({type: Boolean})
-  _prefsChanged = false;
+  @query('#emailEditor', true) emailEditor!: GrEmailEditor;
 
-  @property({type: Boolean})
-  _diffPrefsChanged = false;
+  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _watchedProjectsChanged = false;
+  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _keysChanged = false;
+  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _gpgKeysChanged = false;
+  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
 
-  @property({type: String})
-  _newEmail?: string;
+  @query('#disableKeyboardShortcuts')
+  disableKeyboardShortcuts!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _addingEmail = false;
+  @query('#disableTokenHighlighting')
+  disableTokenHighlighting!: HTMLInputElement;
 
-  @property({type: String})
-  _lastSentVerificationEmail?: string | null = null;
+  @query('#relativeDateInChangeTable')
+  relativeDateInChangeTable!: HTMLInputElement;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _docsBaseUrl?: string | null;
+  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _emailsChanged = false;
+  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _showNumber?: boolean;
+  @query('#emailNotificationsSelect')
+  emailNotificationsSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _isDark = false;
+  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
 
+  @query('#defaultBaseForMergesSelect')
+  defaultBaseForMergesSelect!: HTMLInputElement;
+
+  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
+
+  @state() prefs: PreferencesInput = {};
+
+  @property({type: Object}) params?: AppElementParams;
+
+  @state() private accountInfoChanged = false;
+
+  @state() private localPrefs: PreferencesInput = {};
+
+  // private but used in test
+  @state() localChangeTableColumns: string[] = [];
+
+  @state() private loading = true;
+
+  @state() private changeTableChanged = false;
+
+  // private but used in test
+  @state() prefsChanged = false;
+
+  @state() private diffPrefsChanged = false;
+
+  @state() private watchedProjectsChanged = false;
+
+  @state() private keysChanged = false;
+
+  @state() private gpgKeysChanged = false;
+
+  // private but used in test
+  @state() newEmail?: string;
+
+  // private but used in test
+  @state() addingEmail = false;
+
+  // private but used in test
+  @state() lastSentVerificationEmail?: string | null = null;
+
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
+
+  // private but used in test
+  @state() docsBaseUrl?: string | null;
+
+  @state() private emailsChanged = false;
+
+  // private but used in test
+  @state() showNumber?: boolean;
+
+  // private but used in test
+  @state() isDark = false;
+
+  // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -213,14 +205,16 @@
     // we need to manually calling scrollIntoView when hash changed
     window.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange(this, 'Settings');
+  }
 
-    this._isDark = !!window.localStorage.getItem('dark-theme');
+  override firstUpdated() {
+    this.isDark = !!window.localStorage.getItem('dark-theme');
 
     const promises: Array<Promise<unknown>> = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
     ];
 
     // TODO(dhruvsri): move this to the service
@@ -230,9 +224,9 @@
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
-        this._showNumber = !!prefs.legacycid_in_change_table;
-        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._localChangeTableColumns =
+        this.showNumber = !!prefs.legacycid_in_change_table;
+        this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this.localChangeTableColumns =
           prefs.change_table.length === 0
             ? columnNames
             : prefs.change_table.map(column =>
@@ -243,24 +237,20 @@
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
+        if (this.serverConfig?.sshd && this.sshEditor) {
+          configPromises.push(this.sshEditor.loadData());
         }
 
-        if (
-          this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push
-        ) {
-          configPromises.push(this.$.gpgEditor.loadData());
+        if (this.serverConfig?.receive?.enable_signed_push && this.gpgEditor) {
+          configPromises.push(this.gpgEditor.loadData());
         }
 
         configPromises.push(
           getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
-            this._docsBaseUrl = baseUrl;
+            this.docsBaseUrl = baseUrl;
           })
         );
 
@@ -280,34 +270,728 @@
             if (message) {
               fireAlert(this, message);
             }
-            this.$.emailEditor.loadData();
+            this.emailEditor.loadData();
           })
       );
     } else {
-      promises.push(this.$.emailEditor.loadData());
+      promises.push(this.emailEditor.loadData());
     }
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
 
       // Handle anchor tag for initial load
       this.handleLocationChange();
     });
   }
 
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    fontStyles,
+    formStyles,
+    menuPageStyles,
+    pageNavStyles,
+    css`
+      :host {
+        color: var(--primary-text-color);
+      }
+      h2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .newEmailInput {
+        width: 20em;
+      }
+      #email {
+        margin-bottom: var(--spacing-l);
+      }
+      .main section.darkToggle {
+        display: block;
+      }
+      .filters p,
+      .darkToggle p {
+        margin-bottom: var(--spacing-l);
+      }
+      .queryExample em {
+        color: violet;
+      }
+      .toggle {
+        align-items: center;
+        display: flex;
+        margin-bottom: var(--spacing-l);
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    const isLoading = this.loading || this.loading === undefined;
+    return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
+      <div ?hidden=${isLoading}>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile">Profile</a></li>
+            <li><a href="#Preferences">Preferences</a></li>
+            <li><a href="#DiffPreferences">Diff Preferences</a></li>
+            <li><a href="#EditPreferences">Edit Preferences</a></li>
+            <li><a href="#Menu">Menu</a></li>
+            <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+            <li><a href="#Notifications">Notifications</a></li>
+            <li><a href="#EmailAddresses">Email Addresses</a></li>
+            ${when(
+              this.showHttpAuth(),
+              () =>
+                html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
+            )}
+            ${when(
+              this.serverConfig?.sshd,
+              () => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
+            )}
+            ${when(
+              this.serverConfig?.receive?.enable_signed_push,
+              () => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
+            )}
+            <li><a href="#Groups">Groups</a></li>
+            <li><a href="#Identities">Identities</a></li>
+            ${when(
+              this.serverConfig?.auth.use_contributor_agreements,
+              () => html`<li><a href="#Agreements">Agreements</a></li>`
+            )}
+            <li><a href="#MailFilters">Mail Filters</a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="main gr-form-styles">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-labelledby="darkThemeToggleLabel"
+                ?checked=${this.isDark}
+                @change=${this.handleToggleDark}
+                @click=${this.onTapDarkToggle}
+              ></paper-toggle-button>
+              <div id="darkThemeToggleLabel">
+                Dark theme (the toggle reloads the page)
+              </div>
+            </div>
+          </section>
+          <h2
+            id="Profile"
+            class=${this.computeHeaderClass(this.accountInfoChanged)}
+          >
+            Profile
+          </h2>
+          <fieldset id="profile">
+            <gr-account-info
+              id="accountInfo"
+              ?hasUnsavedChanges=${this.accountInfoChanged}
+              @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.accountInfoChanged = e.detail.value;
+              }}
+            ></gr-account-info>
+            <gr-button
+              @click=${() => {
+                this.accountInfo.save();
+              }}
+              ?disabled=${!this.accountInfoChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Preferences"
+            class=${this.computeHeaderClass(this.prefsChanged)}
+          >
+            Preferences
+          </h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="changesPerPageSelect"
+                >Changes per page</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.changes_per_page
+                  )}
+                  @change=${() => {
+                    this.localPrefs.changes_per_page = Number(
+                      this.changesPerPageSelect.value
+                    ) as 10 | 25 | 50 | 100;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect"
+                >Date/time format</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.date_format
+                  )}
+                  @change=${() => {
+                    this.localPrefs.date_format = this.dateTimeFormatSelect
+                      .value as DateFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.time_format
+                  )}
+                  aria-label="Time Format"
+                  @change=${() => {
+                    this.localPrefs.time_format = this.timeFormatSelect
+                      .value as TimeFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect"
+                >Email notifications</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.email_strategy
+                  )}
+                  @change=${() => {
+                    this.localPrefs.email_strategy = this
+                      .emailNotificationsSelect.value as EmailStrategy;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              ?hidden=${!this.convertToString(this.localPrefs.email_format)}
+            >
+              <label class="title" for="emailFormatSelect">Email format</label>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.email_format
+                  )}
+                  @change=${() => {
+                    this.localPrefs.email_format = this.emailFormatSelect
+                      .value as EmailFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section ?hidden=${!this.localPrefs.default_base_for_merges}>
+              <span class="title">Default Base For Merges</span>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.default_base_for_merges
+                  )}
+                  @change=${() => {
+                    this.localPrefs.default_base_for_merges = this
+                      .defaultBaseForMergesSelect.value as DefaultBase;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="defaultBaseForMergesSelect">
+                    <option value="AUTO_MERGE">Auto Merge</option>
+                    <option value="FIRST_PARENT">First Parent</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable"
+                >Show Relative Dates In Changes Table</label
+              >
+              <span class="value">
+                <input
+                  id="relativeDateInChangeTable"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.relative_date_in_change_table}
+                  @change=${() => {
+                    this.localPrefs.relative_date_in_change_table =
+                      this.relativeDateInChangeTable.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <span class="title">Diff view</span>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+                  @change=${() => {
+                    this.localPrefs.diff_view = this.diffViewSelect
+                      .value as DiffViewMode;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label for="showSizeBarsInFileList" class="title"
+                >Show size bars in file list</label
+              >
+              <span class="value">
+                <input
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.size_bar_in_change_table}
+                  @change=${() => {
+                    this.localPrefs.size_bar_in_change_table =
+                      this.showSizeBarsInFileList.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="publishCommentsOnPush" class="title"
+                >Publish comments on push</label
+              >
+              <span class="value">
+                <input
+                  id="publishCommentsOnPush"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.publish_comments_on_push}
+                  @change=${() => {
+                    this.localPrefs.publish_comments_on_push =
+                      this.publishCommentsOnPush.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="workInProgressByDefault" class="title"
+                >Set new changes to "work in progress" by default</label
+              >
+              <span class="value">
+                <input
+                  id="workInProgressByDefault"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.work_in_progress_by_default}
+                  @change=${() => {
+                    this.localPrefs.work_in_progress_by_default =
+                      this.workInProgressByDefault.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="disableKeyboardShortcuts" class="title"
+                >Disable all keyboard shortcuts</label
+              >
+              <span class="value">
+                <input
+                  id="disableKeyboardShortcuts"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+                  @change=${() => {
+                    this.localPrefs.disable_keyboard_shortcuts =
+                      this.disableKeyboardShortcuts.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="disableTokenHighlighting" class="title"
+                >Disable token highlighting on hover</label
+              >
+              <span class="value">
+                <input
+                  id="disableTokenHighlighting"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.disable_token_highlighting}
+                  @change=${() => {
+                    this.localPrefs.disable_token_highlighting =
+                      this.disableTokenHighlighting.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="insertSignedOff" class="title">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input
+                  id="insertSignedOff"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.signed_off_by}
+                  @change=${() => {
+                    this.localPrefs.signed_off_by =
+                      this.insertSignedOff.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <gr-button
+              id="savePrefs"
+              @click=${this.handleSavePreferences}
+              ?disabled=${!this.prefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="DiffPreferences"
+            class=${this.computeHeaderClass(this.diffPrefsChanged)}
+          >
+            Diff Preferences
+          </h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences
+              id="diffPrefs"
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.diffPrefsChanged = e.detail.value;
+              }}
+            ></gr-diff-preferences>
+            <gr-button
+              id="saveDiffPrefs"
+              @click=${() => {
+                this.diffPrefs.save();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
+          <gr-menu-editor></gr-menu-editor>
+          <h2
+            id="ChangeTableColumns"
+            class=${this.computeHeaderClass(this.changeTableChanged)}
+          >
+            Change Table Columns
+          </h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor
+              .showNumber=${this.showNumber}
+              @show-number-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.showNumber = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+              .serverConfig=${this.serverConfig}
+              .defaultColumns=${this.localChangeTableColumns}
+              @displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
+                this.localChangeTableColumns = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+            >
+            </gr-change-table-editor>
+            <gr-button
+              id="saveChangeTable"
+              @click=${this.handleSaveChangeTable}
+              ?disabled=${!this.changeTableChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Notifications"
+            class=${this.computeHeaderClass(this.watchedProjectsChanged)}
+          >
+            Notifications
+          </h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor
+              ?hasUnsavedChanges=${this.watchedProjectsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.watchedProjectsChanged = e.detail.value;
+              }}
+              id="watchedProjectsEditor"
+            ></gr-watched-projects-editor>
+            <gr-button
+              @click=${() => {
+                this.watchedProjectsEditor.save();
+              }}
+              ?disabled=${!this.watchedProjectsChanged}
+              id="_handleSaveWatchedProjects"
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="EmailAddresses"
+            class=${this.computeHeaderClass(this.emailsChanged)}
+          >
+            Email Addresses
+          </h2>
+          <fieldset id="email">
+            <gr-email-editor
+              id="emailEditor"
+              ?hasUnsavedChanges=${this.emailsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.emailsChanged = e.detail.value;
+              }}
+            ></gr-email-editor>
+            <gr-button
+              @click=${() => {
+                this.emailEditor.save();
+              }}
+              ?disabled=${!this.emailsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title">New email address</span>
+              <span class="value">
+                <iron-input
+                  class="newEmailInput"
+                  .bindValue=${this.newEmail}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    this.newEmail = e.detail.value;
+                  }}
+                  @keydown=${this.handleNewEmailKeydown}
+                >
+                  <input
+                    class="newEmailInput"
+                    type="text"
+                    ?disabled=${this.addingEmail}
+                    @keydown=${this.handleNewEmailKeydown}
+                    placeholder="email@example.com"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section
+              id="verificationSentMessage"
+              ?hidden=${!this.lastSentVerificationEmail}
+            >
+              <p>
+                A verification email was sent to
+                <em>${this.lastSentVerificationEmail}</em>. Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              ?disabled=${!this.computeAddEmailButtonEnabled()}
+              @click=${this.handleAddEmailButton}
+              >Send verification</gr-button
+            >
+          </fieldset>
+          ${when(
+            this.showHttpAuth(),
+            () => html` <div>
+              <h2 id="HTTPCredentials">HTTP Credentials</h2>
+              <fieldset>
+                <gr-http-password id="httpPass"></gr-http-password>
+              </fieldset>
+            </div>`
+          )}
+          ${when(
+            this.serverConfig?.sshd,
+            () => html`<h2
+                id="SSHKeys"
+                class=${this.computeHeaderClass(this.keysChanged)}
+              >
+                SSH keys
+              </h2>
+              <gr-ssh-editor
+                id="sshEditor"
+                ?hasUnsavedChanges=${this.keysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.keysChanged = e.detail.value;
+                }}
+              ></gr-ssh-editor>`
+          )}
+          ${when(
+            this.serverConfig?.receive?.enable_signed_push,
+            () => html`<div>
+              <h2
+                id="GPGKeys"
+                class=${this.computeHeaderClass(this.gpgKeysChanged)}
+              >
+                GPG keys
+              </h2>
+              <gr-gpg-editor
+                id="gpgEditor"
+                ?hasUnsavedChanges=${this.gpgKeysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.gpgKeysChanged = e.detail.value;
+                }}
+              ></gr-gpg-editor>
+            </div>`
+          )}
+          <h2 id="Groups">Groups</h2>
+          <fieldset>
+            <gr-group-list id="groupList"></gr-group-list>
+          </fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities
+              id="identities"
+              .serverConfig=${this.serverConfig}
+            ></gr-identities>
+          </fieldset>
+          ${when(
+            this.serverConfig?.auth.use_contributor_agreements,
+            () => html`<h2 id="Agreements">Agreements</h2>
+              <fieldset>
+                <gr-agreements-list id="agreementsList"></gr-agreements-list>
+              </fieldset>`
+          )}
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href=${this.getFilterDocsLink(this.docsBaseUrl)}
+                target="_blank"
+                rel="nofollow"
+                >Gerrit documentation</a
+              >
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em>Owner name</em>
+                      &lt;<em>owner.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em>branch-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em>project-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em>Change ID</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em>change number</em>"
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`;
+  }
+
   override disconnectedCallback() {
     window.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
-  handleUnsavedChangesChanged(e: ValueChangedEvent) {
-    this._keysChanged = !!e.detail.value;
-  }
-
-  _handleGpgEditorHasSavedChanges(e: ValueChangedEvent<boolean>) {
-    this._gpgKeysChanged = e.detail.value;
-  }
-
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -321,180 +1005,82 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _copyPrefs(direction: CopyPrefsDirection) {
-    let to;
-    let from;
+  private copyPrefs(direction: CopyPrefsDirection) {
     if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
-      from = this._localPrefs;
-      to = 'prefs';
+      this.prefs = {
+        ...this.localPrefs,
+      };
     } else {
-      from = this.prefs;
-      to = '_localPrefs';
-    }
-    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+      this.localPrefs = {
+        ...this.prefs,
+      };
     }
   }
 
-  @observe('_localChangeTableColumns', '_showNumber')
-  _handleChangeTableChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._changeTableChanged = true;
-  }
-
-  @observe('_localPrefs.*')
-  _handlePrefsChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set(
-      '_localPrefs.relative_date_in_change_table',
-      this.$.relativeDateInChangeTable.checked
-    );
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set(
-      '_localPrefs.size_bar_in_change_table',
-      this.$.showSizeBarsInFileList.checked
-    );
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set(
-      '_localPrefs.publish_comments_on_push',
-      this.$.publishCommentsOnPush.checked
-    );
-  }
-
-  _handleDisableKeyboardShortcutsChanged() {
-    this.set(
-      '_localPrefs.disable_keyboard_shortcuts',
-      this.$.disableKeyboardShortcuts.checked
-    );
-  }
-
-  _handleDisableTokenHighlightingChanged() {
-    this.set(
-      '_localPrefs.disable_token_highlighting',
-      this.$.disableTokenHighlighting.checked
-    );
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set(
-      '_localPrefs.work_in_progress_by_default',
-      this.$.workInProgressByDefault.checked
-    );
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+  // private but used in test
+  handleSavePreferences() {
+    this.copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
 
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
+      this.prefsChanged = false;
     });
   }
 
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
+  // private but used in test
+  handleSaveChangeTable() {
+    this.prefs.change_table = this.localChangeTableColumns;
+    this.prefs.legacycid_in_change_table = this.showNumber;
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
+      this.changeTableChanged = false;
     });
   }
 
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed?: boolean) {
+  private computeHeaderClass(changed?: boolean) {
     return changed ? 'edited' : '';
   }
 
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e: KeyboardEvent) {
+  // private but used in test
+  handleNewEmailKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
-      this._handleAddEmailButton();
+      this.handleAddEmailButton();
     }
   }
 
-  _isNewEmailValid(newEmail?: string): newEmail is string {
+  // private but used in test
+  isNewEmailValid(newEmail?: string): newEmail is string {
     return !!newEmail && newEmail.includes('@');
   }
 
-  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
+  // private but used in test
+  computeAddEmailButtonEnabled() {
+    return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
   }
 
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) return;
+  // private but used in test
+  handleAddEmailButton() {
+    if (!this.isNewEmailValid(this.newEmail)) return;
 
-    this._addingEmail = true;
-    this.restApiService.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
+    this.addingEmail = true;
+    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+      this.addingEmail = false;
 
       // If it was unsuccessful.
       if (response.status < 200 || response.status >= 300) {
         return;
       }
 
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
+      this.lastSentVerificationEmail = this.newEmail;
+      this.newEmail = '';
     });
   }
 
-  _handleAccountInfoUnsavedChanges(e: ValueChangedEvent<boolean>) {
-    this._accountInfoChanged = e.detail.value;
-  }
-
-  _handleShowNumberChanged(e: ValueChangedEvent<boolean>) {
-    this._showNumber = e.detail.value;
-  }
-
-  _handleDisplayedColumnsChanged(e: ValueChangedEvent<string[]>) {
-    this._localChangeTableColumns = e.detail.value;
-  }
-
-  _handleHasEmailsChanged(e: ValueChangedEvent<boolean>) {
-    this._emailsChanged = e.detail.value;
-  }
-
-  _handleHasProjectsChanged(e: ValueChangedEvent<boolean>) {
-    this._watchedProjectsChanged = e.detail.value;
-  }
-
-  _getFilterDocsLink(docsBaseUrl?: string | null) {
+  // private but used in test
+  getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
       base = GERRIT_DOCS_BASE_URL;
@@ -506,8 +1092,8 @@
     return base + GERRIT_DOCS_FILTER_PATH;
   }
 
-  _handleToggleDark() {
-    if (this._isDark) {
+  private handleToggleDark() {
+    if (this.isDark) {
       window.localStorage.removeItem('dark-theme');
     } else {
       window.localStorage.setItem('dark-theme', 'true');
@@ -515,14 +1101,16 @@
     this.reloadPage();
   }
 
+  // private but used in test
   reloadPage() {
     windowLocationReload();
   }
 
-  _showHttpAuth(config?: ServerInfo) {
-    if (config && config.auth && config.auth.git_basic_auth_policy) {
+  // private but used in test
+  showHttpAuth() {
+    if (this.serverConfig?.auth?.git_basic_auth_policy) {
       return HTTP_AUTH.includes(
-        config.auth.git_basic_auth_policy.toUpperCase()
+        this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
       );
     }
 
@@ -532,57 +1120,17 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapDarkToggle(e: Event) {
+  private onTapDarkToggle(e: Event) {
     e.preventDefault();
   }
 
-  _handleChangesPerPage() {
-    this.set(
-      '_localPrefs.changes_per_page',
-      Number(this.$.changesPerPageSelect.value)
-    );
-  }
-
-  _handleDateFormat() {
-    this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
-  }
-
-  _handleTimeFormat() {
-    this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
-  }
-
-  _handleEmailStrategy() {
-    this.set(
-      '_localPrefs.email_strategy',
-      this.$.emailNotificationsSelect.value
-    );
-  }
-
-  _handleEmailFormat() {
-    this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
-  }
-
-  _handleDefaultBaseForMerges() {
-    this.set(
-      '_localPrefs.default_base_for_merges',
-      this.$.defaultBaseForMergesSelect.value
-    );
-  }
-
-  _handleDiffView() {
-    this.set(
-      '_localPrefs.diff_view',
-      this.$.diffViewSelect.value as DiffViewMode
-    );
-  }
-
   /**
    * bind-value has type string so we have to convert anything inputed
    * to string.
    *
    * This is so typescript template checker doesn't fail.
    */
-  _convertToString(
+  private convertToString(
     key?:
       | DateFormat
       | DefaultBase
@@ -594,10 +1142,6 @@
   ) {
     return key !== undefined ? String(key) : '';
   }
-
-  _handleHasUnsavedChangesChanged(e: ValueChangedEvent<boolean>) {
-    this._diffPrefsChanged = e.detail.value;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
deleted file mode 100644
index 9c2e929..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,591 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-    }
-    h2 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h2);
-      font-weight: var(--font-weight-h2);
-      line-height: var(--line-height-h2);
-    }
-    .newEmailInput {
-      width: 20em;
-    }
-    #email {
-      margin-bottom: var(--spacing-l);
-    }
-    .main section.darkToggle {
-      display: block;
-    }
-    .filters p,
-    .darkToggle p {
-      margin-bottom: var(--spacing-l);
-    }
-    .queryExample em {
-      color: violet;
-    }
-    .toggle {
-      align-items: center;
-      display: flex;
-      margin-bottom: var(--spacing-l);
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-page-nav class="navStyles">
-      <ul>
-        <li><a href="#Profile">Profile</a></li>
-        <li><a href="#Preferences">Preferences</a></li>
-        <li><a href="#DiffPreferences">Diff Preferences</a></li>
-        <li><a href="#EditPreferences">Edit Preferences</a></li>
-        <li><a href="#Menu">Menu</a></li>
-        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-        <li><a href="#Notifications">Notifications</a></li>
-        <li><a href="#EmailAddresses">Email Addresses</a></li>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-        </template>
-        <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys"> SSH Keys </a>
-        </li>
-        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys"> GPG Keys </a>
-        </li>
-        <li><a href="#Groups">Groups</a></li>
-        <li><a href="#Identities">Identities</a></li>
-        <template
-          is="dom-if"
-          if="[[_serverConfig.auth.use_contributor_agreements]]"
-        >
-          <li>
-            <a href="#Agreements">Agreements</a>
-          </li>
-        </template>
-        <li><a href="#MailFilters">Mail Filters</a></li>
-        <gr-endpoint-decorator name="settings-menu-item">
-        </gr-endpoint-decorator>
-      </ul>
-    </gr-page-nav>
-    <div class="main gr-form-styles">
-      <h1 class="heading-1">User Settings</h1>
-      <h2 id="Theme">Theme</h2>
-      <section class="darkToggle">
-        <div class="toggle">
-          <paper-toggle-button
-            aria-labelledby="darkThemeToggleLabel"
-            checked="[[_isDark]]"
-            on-change="_handleToggleDark"
-            on-click="_onTapDarkToggle"
-          ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">
-            Dark theme (the toggle reloads the page)
-          </div>
-        </div>
-      </section>
-      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
-        Profile
-      </h2>
-      <fieldset id="profile">
-        <gr-account-info
-          id="accountInfo"
-          has-unsaved-changes="[[_accountInfoChanged]]"
-          on-unsaved-changes-changed="_handleAccountInfoUnsavedChanges"
-        ></gr-account-info>
-        <gr-button
-          on-click="_handleSaveAccountInfo"
-          disabled="[[!_accountInfoChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
-        Preferences
-      </h2>
-      <fieldset id="preferences">
-        <section>
-          <label class="title" for="changesPerPageSelect"
-            >Changes per page</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
-              on-change="_handleChangesPerPage"
-            >
-              <select id="changesPerPageSelect">
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="dateTimeFormatSelect"
-            >Date/time format</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.date_format)]]"
-              on-change="_handleDateFormat"
-            >
-              <select id="dateTimeFormatSelect">
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-            </gr-select>
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.time_format)]]"
-              aria-label="Time Format"
-              on-change="_handleTimeFormat"
-            >
-              <select id="timeFormatSelect">
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="emailNotificationsSelect"
-            >Email notifications</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
-              on-change="_handleEmailStrategy"
-            >
-              <select id="emailNotificationsSelect">
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <option value="ATTENTION_SET_ONLY">
-                  Only when I am in the attention set
-                </option>
-                <option value="DISABLED">None</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_convertToString(_localPrefs.email_format)]]">
-          <label class="title" for="emailFormatSelect">Email format</label>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_format)]]"
-              on-change="_handleEmailFormat"
-            >
-              <select id="emailFormatSelect">
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-          <span class="title">Default Base For Merges</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
-              on-change="_handleDefaultBaseForMerges"
-            >
-              <select id="defaultBaseForMergesSelect">
-                <option value="AUTO_MERGE">Auto Merge</option>
-                <option value="FIRST_PARENT">First Parent</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="relativeDateInChangeTable"
-            >Show Relative Dates In Changes Table</label
-          >
-          <span class="value">
-            <input
-              id="relativeDateInChangeTable"
-              type="checkbox"
-              checked$="[[_localPrefs.relative_date_in_change_table]]"
-              on-change="_handleRelativeDateInChangeTable"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Diff view</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.diff_view)]]"
-              on-change="_handleDiffView"
-            >
-              <select id="diffViewSelect">
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label for="showSizeBarsInFileList" class="title"
-            >Show size bars in file list</label
-          >
-          <span class="value">
-            <input
-              id="showSizeBarsInFileList"
-              type="checkbox"
-              checked$="[[_localPrefs.size_bar_in_change_table]]"
-              on-change="_handleShowSizeBarsInFileListChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="publishCommentsOnPush" class="title"
-            >Publish comments on push</label
-          >
-          <span class="value">
-            <input
-              id="publishCommentsOnPush"
-              type="checkbox"
-              checked$="[[_localPrefs.publish_comments_on_push]]"
-              on-change="_handlePublishCommentsOnPushChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="workInProgressByDefault" class="title"
-            >Set new changes to "work in progress" by default</label
-          >
-          <span class="value">
-            <input
-              id="workInProgressByDefault"
-              type="checkbox"
-              checked$="[[_localPrefs.work_in_progress_by_default]]"
-              on-change="_handleWorkInProgressByDefault"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableKeyboardShortcuts" class="title"
-            >Disable all keyboard shortcuts</label
-          >
-          <span class="value">
-            <input
-              id="disableKeyboardShortcuts"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
-              on-change="_handleDisableKeyboardShortcutsChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableTokenHighlighting" class="title"
-            >Disable token highlighting on hover</label
-          >
-          <span class="value">
-            <input
-              id="disableTokenHighlighting"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_token_highlighting]]"
-              on-change="_handleDisableTokenHighlightingChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="insertSignedOff" class="title">
-            Insert Signed-off-by Footer For Inline Edit Changes
-          </label>
-          <span class="value">
-            <input
-              id="insertSignedOff"
-              type="checkbox"
-              checked$="[[_localPrefs.signed_off_by]]"
-              on-change="_handleInsertSignedOff"
-            />
-          </span>
-        </section>
-        <gr-button
-          id="savePrefs"
-          on-click="_handleSavePreferences"
-          disabled="[[!_prefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="DiffPreferences"
-        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
-      >
-        Diff Preferences
-      </h2>
-      <fieldset id="diffPreferences">
-        <gr-diff-preferences
-          id="diffPrefs"
-          on-has-unsaved-changes-changed="_handleHasUnsavedChangesChanged"
-        ></gr-diff-preferences>
-        <gr-button
-          id="saveDiffPrefs"
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
-      <gr-menu-editor></gr-menu-editor>
-      <h2
-        id="ChangeTableColumns"
-        class$="[[_computeHeaderClass(_changeTableChanged)]]"
-      >
-        Change Table Columns
-      </h2>
-      <fieldset id="changeTableColumns">
-        <gr-change-table-editor
-          show-number="[[_showNumber]]"
-          on-show-number-changed="_handleShowNumberChanged"
-          server-config="[[_serverConfig]]"
-          displayed-columns="[[_localChangeTableColumns]]"
-          on-displayed-columns-changed="_handleDisplayedColumnsChanged"
-        >
-        </gr-change-table-editor>
-        <gr-button
-          id="saveChangeTable"
-          on-click="_handleSaveChangeTable"
-          disabled="[[!_changeTableChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="Notifications"
-        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
-      >
-        Notifications
-      </h2>
-      <fieldset id="watchedProjects">
-        <gr-watched-projects-editor
-          has-unsaved-changes="[[_watchedProjectsChanged]]"
-          on-has-unsaved-changes-changed="_handleHasProjectsChanged"
-          id="watchedProjectsEditor"
-        ></gr-watched-projects-editor>
-        <gr-button
-          on-click="_handleSaveWatchedProjects"
-          disabled$="[[!_watchedProjectsChanged]]"
-          id="_handleSaveWatchedProjects"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
-        Email Addresses
-      </h2>
-      <fieldset id="email">
-        <gr-email-editor
-          id="emailEditor"
-          has-unsaved-changes="[[_emailsChanged]]"
-          on-has-unsaved-changes-changed="_handleHasEmailsChanged"
-        ></gr-email-editor>
-        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <fieldset id="newEmail">
-        <section>
-          <span class="title">New email address</span>
-          <span class="value">
-            <iron-input
-              class="newEmailInput"
-              bind-value="{{_newEmail}}"
-              type="text"
-              on-keydown="_handleNewEmailKeydown"
-              placeholder="email@example.com"
-            >
-              <input
-                class="newEmailInput"
-                type="text"
-                disabled="[[_addingEmail]]"
-                on-keydown="_handleNewEmailKeydown"
-                placeholder="email@example.com"
-              />
-            </iron-input>
-          </span>
-        </section>
-        <section
-          id="verificationSentMessage"
-          hidden$="[[!_lastSentVerificationEmail]]"
-        >
-          <p>
-            A verification email was sent to
-            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-          </p>
-        </section>
-        <gr-button
-          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-          on-click="_handleAddEmailButton"
-          >Send verification</gr-button
-        >
-      </fieldset>
-      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-        <div>
-          <h2 id="HTTPCredentials">HTTP Credentials</h2>
-          <fieldset>
-            <gr-http-password id="httpPass"></gr-http-password>
-          </fieldset>
-        </div>
-      </template>
-      <div hidden$="[[!_serverConfig.sshd]]">
-        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
-          SSH keys
-        </h2>
-        <gr-ssh-editor
-          id="sshEditor"
-          has-unsaved-changes-changed="handleUnsavedChangesChanged"
-        ></gr-ssh-editor>
-      </div>
-      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
-          GPG keys
-        </h2>
-        <gr-gpg-editor
-          id="gpgEditor"
-          has-unsaved-changes="[[_gpgKeysChanged]]"
-          on-has-unsaved-changes-changed="_handleGpgEditorHasSavedChanges"
-        ></gr-gpg-editor>
-      </div>
-      <h2 id="Groups">Groups</h2>
-      <fieldset>
-        <gr-group-list id="groupList"></gr-group-list>
-      </fieldset>
-      <h2 id="Identities">Identities</h2>
-      <fieldset>
-        <gr-identities
-          id="identities"
-          server-config="[[_serverConfig]]"
-        ></gr-identities>
-      </fieldset>
-      <template
-        is="dom-if"
-        if="[[_serverConfig.auth.use_contributor_agreements]]"
-      >
-        <h2 id="Agreements">Agreements</h2>
-        <fieldset>
-          <gr-agreements-list id="agreementsList"></gr-agreements-list>
-        </fieldset>
-      </template>
-      <h2 id="MailFilters">Mail Filters</h2>
-      <fieldset class="filters">
-        <p>
-          Gerrit emails include metadata about the change to support writing
-          mail filters.
-        </p>
-        <p>
-          Here are some example Gmail queries that can be used for filters or
-          for searching through archived messages. View the
-          <a
-            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-            target="_blank"
-            rel="nofollow"
-            >Gerrit documentation</a
-          >
-          for the complete set of footers.
-        </p>
-        <table>
-          <tbody>
-            <tr>
-              <th>Name</th>
-              <th>Query</th>
-            </tr>
-            <tr>
-              <td>Changes requesting my review</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Reviewer: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes requesting my attention</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Attention: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes from a specific owner</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Owner: <em>Owner name</em>
-                  &lt;<em>owner.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes targeting a specific branch</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Branch: <em>branch-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes in a specific project</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Project: <em>project-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific Change ID</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Id: <em>Change ID</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific change number</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Number: <em>change number</em>"
-                </code>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </fieldset>
-      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 7f81f42..a514f00 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -19,7 +19,7 @@
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
 import {GerritView} from '../../../services/router/router-model';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -56,7 +56,7 @@
   let config: ServerInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`);
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -122,10 +122,420 @@
     stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
     stubRestApi('getConfig').returns(Promise.resolve(config));
     element = basicFixture.instantiate();
+    await element.updateComplete;
 
     // Allow the element to render.
     if (element._testOnly_loadingPromise)
       await element._testOnly_loadingPromise;
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    sinon
+      .stub(element, 'getFilterDocsLink')
+      .returns('https://test.com/user-notify.html');
+    element.docsBaseUrl = 'https://test.com';
+    await element.updateComplete;
+    // this cannot be formatted with /* HTML */, because it breaks test
+    expect(element).shadowDom.to.equal(/* HTML*/ `<div
+        class="loading"
+        hidden=""
+      >
+        Loading...
+      </div>
+      <div>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile"> Profile </a></li>
+            <li><a href="#Preferences"> Preferences </a></li>
+            <li><a href="#DiffPreferences"> Diff Preferences </a></li>
+            <li><a href="#EditPreferences"> Edit Preferences </a></li>
+            <li><a href="#Menu"> Menu </a></li>
+            <li><a href="#ChangeTableColumns"> Change Table Columns </a></li>
+            <li><a href="#Notifications"> Notifications </a></li>
+            <li><a href="#EmailAddresses"> Email Addresses </a></li>
+            <li><a href="#Groups"> Groups </a></li>
+            <li><a href="#Identities"> Identities </a></li>
+            <li><a href="#MailFilters"> Mail Filters </a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="gr-form-styles main">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-disabled="false"
+                aria-labelledby="darkThemeToggleLabel"
+                aria-pressed="false"
+                role="button"
+                style="touch-action: none;"
+                tabindex="0"
+                toggles=""
+              >
+              </paper-toggle-button>
+              <div id="darkThemeToggleLabel">
+                Dark theme (the toggle reloads the page)
+              </div>
+            </div>
+          </section>
+          <h2 id="Profile">Profile</h2>
+          <fieldset id="profile">
+            <gr-account-info id="accountInfo"> </gr-account-info>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Preferences">Preferences</h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="changesPerPageSelect">
+                Changes per page
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect">
+                Date/time format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select aria-label="Time Format">
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect">
+                Email notifications
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailFormatSelect">
+                Email format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Default Base For Merges </span>
+              <span class="value">
+                <gr-select>
+                  <select id="defaultBaseForMergesSelect">
+                    <option value="AUTO_MERGE">Auto Merge</option>
+                    <option value="FIRST_PARENT">First Parent</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable">
+                Show Relative Dates In Changes Table
+              </label>
+              <span class="value">
+                <input id="relativeDateInChangeTable" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <span class="title"> Diff view </span>
+              <span class="value">
+                <gr-select>
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showSizeBarsInFileList">
+                Show size bars in file list
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="publishCommentsOnPush">
+                Publish comments on push
+              </label>
+              <span class="value">
+                <input id="publishCommentsOnPush" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="workInProgressByDefault">
+                Set new changes to "work in progress" by default
+              </label>
+              <span class="value">
+                <input id="workInProgressByDefault" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableKeyboardShortcuts">
+                Disable all keyboard shortcuts
+              </label>
+              <span class="value">
+                <input id="disableKeyboardShortcuts" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableTokenHighlighting">
+                Disable token highlighting on hover
+              </label>
+              <span class="value">
+                <input id="disableTokenHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="insertSignedOff">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input id="insertSignedOff" type="checkbox" />
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="savePrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="DiffPreferences">Diff Preferences</h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveDiffPrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"> </gr-edit-preferences>
+          <gr-menu-editor> </gr-menu-editor>
+          <h2 id="ChangeTableColumns">Change Table Columns</h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor> </gr-change-table-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveChangeTable"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Notifications">Notifications</h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor id="watchedProjectsEditor">
+            </gr-watched-projects-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="_handleSaveWatchedProjects"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="EmailAddresses">Email Addresses</h2>
+          <fieldset id="email">
+            <gr-email-editor id="emailEditor"> </gr-email-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title"> New email address </span>
+              <span class="value">
+                <iron-input class="newEmailInput">
+                  <input
+                    class="newEmailInput"
+                    placeholder="email@example.com"
+                    type="text"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section hidden="" id="verificationSentMessage">
+              <p>
+                A verification email was sent to <em>
+                </em>
+               . Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Send verification
+            </gr-button>
+          </fieldset> 
+          <h2 id="Groups">Groups</h2>
+          <fieldset><gr-group-list id="groupList"> </gr-group-list></fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities id="identities"> </gr-identities>
+          </fieldset>
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href="https://test.com/user-notify.html"
+                rel="nofollow"
+                target="_blank"
+              >
+                Gerrit documentation
+              </a>
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em> Owner name </em> <
+                      <em> owner.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em> branch-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em> project-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em> Change ID </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em> change number </em> "
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`);
   });
 
   test('theme changing', async () => {
@@ -141,7 +551,7 @@
     assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
     assert.isTrue(reloadStub.calledOnce);
 
-    element._isDark = true;
+    element.isDark = true;
     await flush();
     MockInteractions.tap(themeToggle);
     assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
@@ -258,14 +668,14 @@
       false
     );
 
-    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element.prefsChanged);
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
       .firstElementChild!;
 
     MockInteractions.tap(publishOnPush);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
@@ -274,8 +684,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('publish comments on push', async () => {
@@ -285,7 +695,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(publishCommentsOnPush);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.publish_comments_on_push, true);
@@ -293,8 +703,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('set new changes work-in-progress', async () => {
@@ -304,7 +714,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(newChangesWorkInProgress);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.work_in_progress_by_default, true);
@@ -312,37 +722,40 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+  test('add email validation', async () => {
+    assert.isFalse(element.isNewEmailValid('invalid email'));
+    assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
 
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('invalid email', true)
-    );
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
-    );
-    assert.isTrue(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
-    );
+    element.newEmail = 'invalid email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeAddEmailButtonEnabled());
   });
 
   test('add email does not save invalid', () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'invalid email';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isFalse(element._addingEmail);
+    assert.isFalse(element.addingEmail);
     assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
 
     assert.isFalse(addEmailStub.called);
   });
@@ -350,62 +763,59 @@
   test('add email does save valid', async () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isTrue(element._addingEmail);
+    assert.isTrue(element.addingEmail);
     assert.isTrue(addEmailStub.called);
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isOk(element._lastSentVerificationEmail);
+    assert.isOk(element.lastSentVerificationEmail);
   });
 
   test('add email does not set last-email if error', async () => {
     const addEmailStub = stubAddAccountEmail(500);
 
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
   });
 
   test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(
-      element.$.emailEditor,
-      'loadData'
-    );
+    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
     element.params = {
       view: GerritView.SETTINGS,
     } as AppElementSettingsParam;
-    element.connectedCallback();
+    element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
 
-  test('_handleSaveChangeTable', () => {
+  test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns.slice(0);
+    element.showNumber = false;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
     newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns;
+    element.showNumber = true;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('_showHttpAuth', () => {
+  test('showHttpAuth', async () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -413,41 +823,48 @@
       } as AuthInfo,
     };
 
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig = serverConfig;
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'LDAP';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'OAUTH';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    assert.isFalse(element._showHttpAuth(undefined));
+    element.serverConfig = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
   });
 
-  suite('_getFilterDocsLink', () => {
+  suite('getFilterDocsLink', () => {
     test('with http: docs base URL', () => {
       const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with http: docs base URL without slash', () => {
       const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with https: docs base URL', () => {
       const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'https://example.com/user-notify.html');
     });
 
     test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
+      const result = element.getFilterDocsLink(null);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -457,7 +874,7 @@
 
     test('ignores non HTTP links', () => {
       const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -474,7 +891,7 @@
     let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
@@ -482,7 +899,7 @@
       );
 
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.connectedCallback();
+      element.firstUpdated();
     });
 
     test('it is used to confirm email via rest API', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index d585252..99b72fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -147,15 +147,7 @@
           background-color: var(--view-background-color);
           width: 100%;
           display: block;
-
-          /* You have to also repeat everything from shared-styles here, because
-              you can only *replace* --iron-autogrow-textarea vars as a whole. */
-          --iron-autogrow-textarea: {
-            box-sizing: border-box;
-            padding: var(--spacing-m);
-            overflow-y: hidden;
-            white-space: pre;
-          }
+          --iron-autogrow-textarea_-_padding: var(--spacing-m);
         }
         .editButtons {
           display: flex;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 18ea5de..79c73f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -25,6 +25,7 @@
   PrimaryActionKey,
   RevisionActions,
 } from '../../../api/change-actions';
+import {PropertyDeclaration} from 'lit';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -40,7 +41,6 @@
   ChangeActions: Record<string, string>;
   ActionType: Record<string, string>;
   primaryActionKeys: string[];
-  push(propName: 'primaryActionKeys', value: string): void;
   hideQuickApproveAction(): void;
   setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
   setActionPriority(
@@ -57,6 +57,11 @@
     value: UIActionInfo[T]
   ): void;
   getActionDetails(actionName: string): ActionInfo | undefined;
+  requestUpdate(
+    name?: PropertyKey,
+    oldValue?: unknown,
+    options?: PropertyDeclaration
+  ): void;
 }
 
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
@@ -111,7 +116,8 @@
       return;
     }
 
-    el.push('primaryActionKeys', key);
+    el.primaryActionKeys.push(key);
+    el.requestUpdate();
   }
 
   removePrimaryActionKey(key: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index fceb518..8d1c770 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -32,10 +32,10 @@
   ChangeActionsPluginApi,
   PrimaryActionKey,
 } from '../../../api/change-actions';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../gr-button/gr-button';
 import {IronIconElement} from '@polymer/iron-icon';
 import {ChangeViewChangeInfo} from '../../../types/common';
+import {GrDropdown} from '../gr-dropdown/gr-dropdown';
 
 suite('gr-change-actions-js-api-interface tests', () => {
   let element: GrChangeActions;
@@ -77,7 +77,7 @@
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
-      sinon.stub(element, '_editStatusChanged');
+      sinon.stub(element, 'editStatusChanged');
       element.change = {} as ChangeViewChangeInfo;
       element._hasKnownChainState = false;
       window.Gerrit.install(
@@ -118,22 +118,21 @@
       assert.deepEqual(element.primaryActionKeys, []);
     });
 
-    test('action buttons', () => {
+    test('action buttons', async () => {
       const key = changeActions.add(ActionType.REVISION, 'Bork!');
       const handler = sinon.spy();
       changeActions.addTapListener(key, handler);
-      flush();
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
-      );
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
       assert(handler.calledOnce);
       changeActions.removeTapListener(key, handler);
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
-      );
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
       assert(handler.calledOnce);
       changeActions.remove(key);
-      flush();
+      await element.updateComplete;
       assert.isUndefined(
         query<GrButton>(element, `[data-action-key="${key}"]`)
       );
@@ -141,7 +140,7 @@
 
     test('action button properties', async () => {
       const key = changeActions.add(ActionType.REVISION, 'Bork!');
-      flush();
+      await element.updateComplete;
       const button = queryAndAssert<GrButton>(
         element,
         `[data-action-key="${key}"]`
@@ -153,7 +152,7 @@
       changeActions.setTitle(key, 'Yo hint');
       changeActions.setEnabled(key, false);
       changeActions.setIcon(key, 'pupper');
-      await flush();
+      await element.updateComplete;
       assert.equal(button.getAttribute('data-label'), 'Yo');
       assert.equal(button.parentElement!.getAttribute('title'), 'Yo hint');
       assert.isTrue(button.disabled);
@@ -165,39 +164,44 @@
 
     test('hide action buttons', async () => {
       const key = changeActions.add(ActionType.REVISION, 'Bork!');
-      await flush();
+      await element.updateComplete;
       let button = query<GrButton>(element, `[data-action-key="${key}"]`);
       assert.isOk(button);
       assert.isFalse(button!.hasAttribute('hidden'));
       changeActions.setActionHidden(ActionType.REVISION, key, true);
-      flush();
+      await element.updateComplete;
       button = query<GrButton>(element, `[data-action-key="${key}"]`);
       assert.isNotOk(button);
     });
 
     test('move action button to overflow', async () => {
       const key = changeActions.add(ActionType.REVISION, 'Bork!');
-      await flush();
-      assert.isTrue(element.$.moreActions.hidden);
+      await element.updateComplete;
+      assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
       assert.isOk(
         queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
       );
       changeActions.setActionOverflow(ActionType.REVISION, key, true);
-      await flush();
+      await element.updateComplete;
       assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
-      assert.isFalse(element.$.moreActions.hidden);
-      assert.strictEqual(element.$.moreActions.items![0].name, 'Bork!');
+      assert.isFalse(
+        queryAndAssert<GrDropdown>(element, '#moreActions').hidden
+      );
+      assert.strictEqual(
+        queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
+        'Bork!'
+      );
     });
 
-    test('change actions priority', () => {
+    test('change actions priority', async () => {
       const key1 = changeActions.add(ActionType.REVISION, 'Bork!');
       const key2 = changeActions.add(ActionType.CHANGE, 'Squanch?');
-      flush();
+      await element.updateComplete;
       let buttons = queryAll<GrButton>(element, '[data-action-key]');
       assert.equal(buttons[0].getAttribute('data-action-key'), key1);
       assert.equal(buttons[1].getAttribute('data-action-key'), key2);
       changeActions.setActionPriority(ActionType.REVISION, key1, 10);
-      flush();
+      await element.updateComplete;
       buttons = queryAll<GrButton>(element, '[data-action-key]');
       assert.equal(buttons[0].getAttribute('data-action-key'), key2);
       assert.equal(buttons[1].getAttribute('data-action-key'), key1);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index e426e66..c72fc68 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -40,7 +40,11 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {
+  DiffViewMode,
+  RenderPreferences,
+  RenderProgressEventDetail,
+} from '../../../api/diff';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {
@@ -49,7 +53,7 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fire} from '../../../utils/event-util';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -58,6 +62,12 @@
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
+declare global {
+  interface HTMLElementEventMap {
+    'render-progress': CustomEvent<RenderProgressEventDetail>;
+  }
+}
+
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -97,19 +107,22 @@
   }
 
   /**
-   * Fired when the diff begins rendering.
+   * Fired when the diff begins rendering - both for full renders and for
+   * partial rerenders.
    *
    * @event render-start
    */
 
   /**
-   * Fired whenever a new chunk of lines has been rendered synchronously.
+   * Fired whenever a new chunk of lines has been rendered synchronously - this
+   * only happens for full renders.
    *
    * @event render-progress
    */
 
   /**
-   * Fired when the diff finishes rendering text content.
+   * Fired when the diff finishes rendering text content - both for full
+   * renders and for partial rerenders.
    *
    * @event render-content
    */
@@ -282,7 +295,7 @@
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
-        fireEvent(this, 'render-content');
+        afterNextRender(this, () => fireEvent(this, 'render-content'));
       })
     );
     return (
@@ -393,8 +406,7 @@
         lineRange.end_line - lineRange.start_line + 1
       )
     );
-    this._builder.replaceGroup(group, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    this.replaceGroup(group, newGroups);
   }
 
   /**
@@ -410,8 +422,16 @@
     newGroups: readonly GrDiffGroup[]
   ) {
     if (!this._builder) return;
+    fireEvent(this, 'render-start');
+    const linesRendered = newGroups.reduce(
+      (sum, group) => sum + group.lines.length,
+      0
+    );
     this._builder.replaceGroup(contextGroup, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    afterNextRender(this, () => {
+      fire(this, 'render-progress', {linesRendered});
+      fireEvent(this, 'render-content');
+    });
   }
 
   cancel() {
@@ -505,7 +525,9 @@
   addGroup(group: GrDiffGroup) {
     if (!this._builder) return;
     this._builder.addGroups([group]);
-    fireEvent(this, 'render-progress');
+    afterNextRender(this, () =>
+      fire(this, 'render-progress', {linesRendered: group.lines.length})
+    );
   }
 
   _createIntralineLayer(): DiffLayer {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 0ad21b0..6cbbc62 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -661,6 +661,7 @@
     });
 
     test('render-start and render-content are fired', async () => {
+      await new Promise(resolve => afterNextRender(element, resolve));
       const firedEventTypes = element.dispatchEvent.getCalls()
           .map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-start');
@@ -738,7 +739,6 @@
     });
 
     test('unhideLine shows the line with context', async () => {
-      const clock = sinon.useFakeTimers();
       element.dispatchEvent.reset();
       element.unhideLine(4, Side.LEFT);
 
@@ -759,8 +759,7 @@
       assert.include(diffRows[8].textContent, 'after');
       assert.include(diffRows[9].textContent, 'unchanged 11');
 
-      clock.tick(1);
-      await flush();
+      await new Promise(resolve => afterNextRender(element, resolve));
       const firedEventTypes = element.dispatchEvent.getCalls()
           .map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-content');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 9b093af..f48d673 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -23,6 +23,7 @@
 import {createDiff} from '../../../test/test-data-generators.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {GrDiffCursor} from './gr-diff-cursor.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 
 suite('gr-diff-cursor tests', () => {
   let cursor;
@@ -159,7 +160,7 @@
     };
 
     diffElement.diff = diff;
-    await flush();
+    await new Promise(resolve => afterNextRender(diffElement, resolve));
     cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
@@ -213,16 +214,10 @@
 
   suite('unified diff', () => {
     setup(async () => {
-      const promise = mockPromise();
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
       diffElement.viewMode = 'UNIFIED_DIFF';
-      await promise;
+      // We must allow the diff to re-render after setting the viewMode.
+      await new Promise(resolve => afterNextRender(diffElement, resolve));
+      cursor.reInitCursor();
     });
 
     test('diff cursor functionality (unified)', () => {
@@ -457,19 +452,13 @@
           scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
 
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
     diffElement._diffChanged(createDiff());
-    await promise;
+    await new Promise(resolve => afterNextRender(diffElement, resolve));
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('initialLineNumber provided', async () => {
@@ -479,24 +468,18 @@
           scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
     const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
     cursor.initialLineNumber = 10;
     cursor.side = 'right';
 
     diffElement._diffChanged(createDiff());
-    await promise;
+    await new Promise(resolve => afterNextRender(diffElement, resolve));
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], 'right');
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('getTargetDiffElement', () => {
@@ -548,7 +531,7 @@
         end_line: 6,
         end_character: 1,
       };
-      diffElement.$.highlights.selectedRange = {
+      diffElement.highlights.selectedRange = {
         side: 'right',
         range: someRange,
       };
@@ -618,7 +601,7 @@
     MockInteractions.tap(diffElement.shadowRoot
         .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
-    await flush();
+    await new Promise(resolve => afterNextRender(diffElement, resolve));
     assert.isTrue(cursor._updateStops.called);
   });
 
@@ -664,6 +647,7 @@
       diffElements[0].diff = createDiff();
       diffElements[2].diff = createDiff();
       await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
+      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
 
       const lastLine = diffElements[0].diff.meta_b.lines;
 
@@ -685,6 +669,7 @@
       // Diff 1 finishing to load
       diffElements[1].diff = createDiff();
       await diffRenderedPromises[1];
+      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
 
       // Now we can go down
       cursor.moveDown();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 2ee6c9f..0714645 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -1,43 +1,25 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
 import {GrAnnotation} from './gr-annotation';
 import {normalize} from './gr-range-normalizer';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
 import {
   getLineElByChild,
   getLineNumberByChild,
-  getRange,
-  getSide,
   getSideByLineEl,
   GrDiffThreadElement,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -56,51 +38,65 @@
   end: NormalizedPosition | null;
 }
 
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | null;
+}
 
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
   selectedRange?: SidedRange;
 
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
   private selectionChangeTask?: DelayedTask;
 
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
     );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
     );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
     );
   }
 
-  override disconnectedCallback() {
+  cleanup() {
     this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
     }
-    return this._cachedDiffBuilder;
   }
 
   /**
@@ -129,18 +125,17 @@
     // removed.
     // If you wait longer than 50 ms, then you don't properly catch a very
     // quick 'c' press after the selection change. If you wait less than 10
-    // ms, then you will have about 50 _handleSelection calls when doing a
+    // ms, then you will have about 50 handleSelection() calls when doing a
     // simple drag for select.
     this.selectionChangeTask = debounce(
       this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
+      () => this.handleSelection(selection, isMouseUp),
       10
     );
   }
 
-  _getThreadEl(e: Event): GrDiffThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
       if (
         pathEl instanceof HTMLElement &&
         pathEl.classList.contains('comment-thread')
@@ -151,130 +146,74 @@
     return null;
   }
 
-  _toggleRangeElHighlight(
-    threadEl: GrDiffThreadElement,
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
     highlightRange = false
   ) {
-    // We don't want to re-create the line just for highlighting the range which
-    // is creating annoying bugs: @see Issue 12934
-    // As gr-ranged-comment-layer now does not notify the layer re-render and
-    // lack of access to the thread or the lineEl from the ranged-comment-layer,
-    // need to update range class for styles here.
-    let curNode: HTMLElement | null = threadEl.assignedSlot;
-    while (curNode) {
-      if (curNode.nodeName === 'TABLE') break;
-      curNode = curNode.parentElement;
-    }
-    if (curNode?.querySelectorAll) {
-      if (highlightRange) {
-        const rangeNodes = curNode.querySelectorAll(
-          `.range.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.add('rangeHoverHighlight')
-            );
-        }
-      } else {
-        const rangeNodes = curNode.querySelectorAll(
-          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHoverHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.remove('rangeHoverHighlight')
-            );
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _handleCommentThreadMouseleave(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], false);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
-  }
-
-  _indexForThreadEl(threadEl: HTMLElement) {
-    const side = getSide(threadEl);
-    const range = getRange(threadEl);
-    if (!side || !range) return undefined;
-    return this._indexOfCommentRange(side, range);
-  }
-
-  _indexOfCommentRange(side: Side, range: CommentRange) {
-    function rangesEqual(a: CommentRange, b: CommentRange) {
-      if (!a && !b) {
-        return true;
-      }
-      if (!a || !b) {
-        return false;
-      }
-      return (
-        a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
       );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
     }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
   }
 
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
   /**
    * Get current normalized selection.
    * Merges multiple ranges, accounts for triple click, accounts for
    * syntax highligh, convert native DOM Range objects to Gerrit concepts
    * (line, side, etc).
    */
-  _getNormalizedRange(selection: Selection | Range) {
+  private getNormalizedRange(selection: Selection | Range) {
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
        we can get is a single Range */
     if (selection instanceof Range) {
-      return this._normalizeRange(selection);
+      return this.normalizeRange(selection);
     }
     const rangeCount = selection.rangeCount;
     if (rangeCount === 0) {
       return null;
     } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
+      return this.normalizeRange(selection.getRangeAt(0));
     } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
         selection.getRangeAt(rangeCount - 1)
       );
       return {
@@ -289,15 +228,15 @@
    *
    * @return fixed normalized range
    */
-  _normalizeRange(domRange: Range): NormalizedRange {
+  private normalizeRange(domRange: Range): NormalizedRange {
     const range = normalize(domRange);
-    return this._fixTripleClickSelection(
+    return this.fixTripleClickSelection(
       {
-        start: this._normalizeSelectionSide(
+        start: this.normalizeSelectionSide(
           range.startContainer,
           range.startOffset
         ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
       },
       domRange
     );
@@ -313,7 +252,7 @@
    * @param domRange DOM Range object
    * @return fixed normalized range
    */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
     if (!range.start) {
       // Selection outside of current diff.
       return range;
@@ -334,7 +273,7 @@
       end.column === 0 &&
       end.line === start.line + 1;
     const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
+    const lineLength = (content && this.getLength(content)) || 0;
     if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
       // Move the selection to the end of the previous line.
       range.end = {
@@ -355,12 +294,14 @@
    * @param node td.content child
    * @param offset offset within node
    */
-  _normalizeSelectionSide(
+  private normalizeSelectionSide(
     node: Node | null,
     offset: number
   ): NormalizedPosition | null {
     let column;
-    if (!node || !this.contains(node)) return null;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
     const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
     const side = getSideByLineEl(lineEl);
@@ -376,10 +317,10 @@
     } else {
       const thread = contentTd.querySelector('.comment-thread');
       if (thread?.contains(node)) {
-        column = this._getLength(contentText);
+        column = this.getLength(contentText);
         node = contentText;
       } else {
-        column = this._convertOffsetToColumn(node, offset);
+        column = this.convertOffsetToColumn(node, offset);
       }
     }
 
@@ -398,7 +339,8 @@
    * collapsed section, so don't need to worry about this case for
    * positioning the tooltip.
    */
-  _positionActionBox(
+  // visible for testing
+  positionActionBox(
     actionBox: GrSelectionActionBox,
     startLine: number,
     range: Text | Element | Range
@@ -412,7 +354,7 @@
     actionBox.placeBelow(range);
   }
 
-  _isRangeValid(range: NormalizedRange | null) {
+  private isRangeValid(range: NormalizedRange | null) {
     if (!range || !range.start || !range.start.node || !range.end) {
       return false;
     }
@@ -425,15 +367,16 @@
     );
   }
 
-  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
     /* On Safari, the selection events may return a null range that should
        be ignored */
-    if (!selection) {
-      return;
-    }
-    const normalizedRange = this._getNormalizedRange(selection);
-    if (!this._isRangeValid(normalizedRange)) {
-      this._removeActionBox();
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
       return;
     }
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
@@ -463,8 +406,8 @@
       // start.column with the content length), we just check if the selection
       // is empty to see that it's at the end of a line.
       const content = domRange.cloneContents().querySelector('.contentText');
-      if (isMouseUp && this._getLength(content) === 0) {
-        this._fireCreateRangeComment(start.side, {
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -474,10 +417,10 @@
       return;
     }
 
-    let actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+      this.diffTable.appendChild(actionBox);
     }
     this.selectedRange = {
       range: {
@@ -489,10 +432,10 @@
       side: start.side,
     };
     if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
+      this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
       if (start.column) {
-        this._positionActionBox(
+        this.positionActionBox(
           actionBox,
           start.line,
           start.node.splitText(start.column)
@@ -505,44 +448,41 @@
       (start.node.firstChild instanceof Element ||
         start.node.firstChild instanceof Text)
     ) {
-      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
     } else if (start.node instanceof Element || start.node instanceof Text) {
-      this._positionActionBox(actionBox, start.line, start.node);
+      this.positionActionBox(actionBox, start.line, start.node);
     } else {
       console.warn('Failed to position comment action box.');
-      this._removeActionBox();
+      this.removeActionBox();
     }
   }
 
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
       new CustomEvent('create-range-comment', {
         detail: {side, range},
         composed: true,
         bubbles: true,
       })
     );
-    this._removeActionBox();
+    this.removeActionBox();
   }
 
-  _handleRangeCommentRequest(e: Event) {
+  private handleRangeCommentRequest = (e: Event) => {
     e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
+    assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
+    this.fireCreateRangeComment(side, range);
+  };
 
-  _removeActionBox() {
+  // visible for testing
+  removeActionBox() {
     this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
   }
 
-  _convertOffsetToColumn(el: Node, offset: number) {
+  private convertOffsetToColumn(el: Node, offset: number) {
     if (el instanceof Element && el.classList.contains('content')) {
       return offset;
     }
@@ -552,7 +492,7 @@
     ) {
       if (el.previousSibling) {
         el = el.previousSibling;
-        offset += this._getLength(el);
+        offset += this.getLength(el);
       } else {
         el = el.parentElement!;
       }
@@ -566,18 +506,24 @@
    *
    * @param node this is sometimes passed as null.
    */
-  _getLength(node: Node | null): number {
+  // visible for testing
+  getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
+      return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
   }
 }
 
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
 declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to appear above wrapped content, since it's inserted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +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-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate()[1];
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sinon.stub().returns([]),
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sinon.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = document.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentTdByLine: sinon.stub(),
-        getContentTdByLineEl: sinon.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sinon.stub(),
-        getSideByLineEl: sinon.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      document.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sinon.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sinon.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.parentElement, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = document.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = _getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = _getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..b819754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,713 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-highlight';
+import {_getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {waitQueryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stub('gr-selection-action-box', 'placeAbove');
+      stub('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = _getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = _getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -1,39 +1,23 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
 import {
   normalize,
   NormalizedRange,
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   getLineElByChild,
   getSide,
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -55,49 +39,35 @@
   return {left: null, right: null};
 }
 
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrDiffSelection {
+  // visible for testing
   diff?: DiffInfo;
 
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
+  // visible for testing
+  diffTable?: HTMLElement;
 
-  @property({type: Object})
-  _linesCache: LinesCache = {left: null, right: null};
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
 
-  constructor() {
-    super();
-    this.addEventListener('copy', e => this._handleCopy(e));
-    addListener(this, 'down', e => this._handleDown(e));
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.classList.add(SelectionClass.RIGHT);
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
   }
 
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  @observe('diff')
-  _diffChanged() {
-    this._linesCache = getNewCache();
-  }
-
-  _handleDownOnRangeComment(node: Element) {
+  handleDownOnRangeComment(node: Element) {
     if (isThreadEl(node)) {
-      this._setClasses([
+      this.setClasses([
         SelectionClass.COMMENT,
         getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
@@ -108,14 +78,13 @@
     return false;
   }
 
-  _handleDown(e: Event) {
+  handleDown = (e: Event) => {
     const target = e.target;
     if (!(target instanceof Element)) return;
-    // Handle the down event on comment thread in Polymer 2
-    const handled = this._handleDownOnRangeComment(target);
+    const handled = this.handleDownOnRangeComment(target);
     if (handled) return;
     const lineEl = getLineElByChild(target);
-    const blameSelected = this._elementDescendedFromClass(target, 'blame');
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
     if (!lineEl && !blameSelected) {
       return;
     }
@@ -125,9 +94,10 @@
     if (blameSelected) {
       targetClasses.push(SelectionClass.BLAME);
     } else if (lineEl) {
-      const commentSelected = this._elementDescendedFromClass(
+      const commentSelected = descendedFromClass(
         target,
-        'gr-comment'
+        'gr-comment',
+        this.diffTable
       );
       const side = getSideByLineEl(lineEl);
 
@@ -140,60 +110,50 @@
       }
     }
 
-    this._setClasses(targetClasses);
-  }
+    this.setClasses(targetClasses);
+  };
 
   /**
    * Set the provided list of classes on the element, to the exclusion of all
    * other SelectionClass values.
    */
-  _setClasses(targetClasses: string[]) {
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
     // Remove any selection classes that do not belong.
     for (const className of Object.values(SelectionClass)) {
       if (!targetClasses.includes(className)) {
-        this.classList.remove(className);
+        this.diffTable.classList.remove(className);
       }
     }
     // Add new selection classes iff they are not already present.
-    for (const _class of targetClasses) {
-      if (!this.classList.contains(_class)) {
-        this.classList.add(_class);
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
       }
     }
   }
 
-  _getCopyEventTarget(e: Event) {
-    return (dom(e) as EventApi).rootTarget;
-  }
-
-  /**
-   * Utility function to determine whether an element is a descendant of
-   * another element with the particular className.
-   */
-  _elementDescendedFromClass(element: Element, className: string) {
-    return descendedFromClass(element, className, this.diffBuilder.diffElement);
-  }
-
-  _handleCopy(e: ClipboardEvent) {
+  handleCopy = (e: ClipboardEvent) => {
     let commentSelected = false;
-    const target = this._getCopyEventTarget(e);
+    const target = e.composedPath()[0];
     if (!(target instanceof Element)) return;
     if (target instanceof HTMLTextAreaElement) return;
-    if (!this._elementDescendedFromClass(target, 'diff-row')) return;
-    if (this.classList.contains(SelectionClass.COMMENT)) {
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this._getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
     }
-  }
+  };
 
-  _getSelection() {
+  getSelection() {
     const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return document.getSelection();
 
@@ -219,13 +179,13 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  _getSelectedText(side: Side, commentSelected: boolean) {
-    const sel = this._getSelection();
+  getSelectedText(side: Side, commentSelected: boolean) {
+    const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
     if (commentSelected) {
-      return this._getCommentLines(sel, side);
+      return this.getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
@@ -250,7 +210,7 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this._getRangeFromDiff(
+    return this.getRangeFromDiff(
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -262,7 +222,7 @@
   /**
    * Query the diff object for the selected lines.
    */
-  _getRangeFromDiff(
+  getRangeFromDiff(
     startLineNum: number,
     startOffset: number,
     endLineNum: number | undefined,
@@ -274,7 +234,7 @@
       startLineNum -= skipChunk.skip!;
       if (endLineNum) endLineNum -= skipChunk.skip!;
     }
-    const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
     if (lines.length) {
       lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
       lines[0] = lines[0].substring(startOffset);
@@ -288,9 +248,9 @@
    * @param side The side that is currently selected.
    * @return An array of strings indexed by line number.
    */
-  _getDiffLines(side: Side): string[] {
-    if (this._linesCache[side]) {
-      return this._linesCache[side]!;
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
     }
     if (!this.diff) return [];
     let lines: string[] = [];
@@ -303,7 +263,7 @@
         lines = lines.concat(chunk.b);
       }
     }
-    this._linesCache[side] = lines;
+    this.linesCache[side] = lines;
     return lines;
   }
 
@@ -315,11 +275,11 @@
    * @param side The side that is currently selected.
    * @return The selected comment text.
    */
-  _getCommentLines(sel: Selection, side: Side) {
+  getCommentLines(sel: Selection, side: Side) {
     const range = normalize(sel.getRangeAt(0));
     const content = [];
-    // Query the diffElement for comments.
-    const messages = this.diffBuilder.diffElement.querySelectorAll(
+    assertIsDefined(this.diffTable, 'diffTable');
+    const messages = this.diffTable.querySelectorAll(
       `.side-by-side [data-side="${side}"] .message *, .unified .message *`
     );
 
@@ -339,9 +299,9 @@
 
         if (
           el.id === 'output' &&
-          !this._elementDescendedFromClass(el, 'collapsed')
+          !descendedFromClass(el, 'collapsed', this.diffTable)
         ) {
-          content.push(this._getTextContentForRange(el, sel, range));
+          content.push(this.getTextContentForRange(el, sel, range));
         }
       }
     }
@@ -359,7 +319,7 @@
    * @param range The normalized selection range.
    * @return The text within the selection.
    */
-  _getTextContentForRange(
+  getTextContentForRange(
     domNode: Node,
     sel: Selection,
     range: NormalizedRange
@@ -379,15 +339,9 @@
       }
     } else {
       for (const childNode of domNode.childNodes) {
-        text += this._getTextContentForRange(childNode, sel, range);
+        text += this.getTextContentForRange(childNode, sel, range);
       }
     }
     return text;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-selection': GrDiffSelection;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index dc7f6a2..b44114a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -5,121 +5,115 @@
  */
 import '../../../test/common-test-setup-karma';
 import './gr-diff-selection';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrDiffSelection} from './gr-diff-selection';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {createDiff} from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {Side} from '../../../api/diff';
+import {DiffInfo, Side} from '../../../api/diff';
 import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mouseDown} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-diff-selection>
-    <table id="diffTable" class="side-by-side">
-      <tr class="diff-row">
-        <td class="blame" data-line-number="1"></td>
-        <td class="lineNum left" data-value="1">1</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ba ba</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is a comment</span
-                >
-              </div>
+const diffTableTemplate = html`
+  <table id="diffTable" class="side-by-side">
+    <tr class="diff-row">
+      <td class="blame" data-line-number="1"></td>
+      <td class="lineNum left" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ba ba</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text">This is a comment</span>
             </div>
           </div>
-        </td>
-        <td class="lineNum right" data-value="1">1</td>
-        <td class="content">
-          <div class="contentText" data-side="right">some other text</div>
-        </td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="2"></td>
-        <td class="lineNum left" data-value="2">2</td>
-        <td class="content">
-          <div class="contentText" data-side="left">zin</div>
-        </td>
-        <td class="lineNum right" data-value="2">2</td>
-        <td class="content">
-          <div class="contentText" data-side="right">more more more</div>
-          <div data-side="right">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is a comment on the right</span
-                >
-              </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="2"></td>
+      <td class="lineNum left" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="left">zin</div>
+      </td>
+      <td class="lineNum right" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="right">more more more</div>
+        <div data-side="right">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is a comment on the right</span
+              >
             </div>
           </div>
-        </td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="3"></td>
-        <td class="lineNum left" data-value="3">3</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ga ga</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is <a>a</a> different comment 💩 unicode is fun</span
-                >
-              </div>
+        </div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="3"></td>
+      <td class="lineNum left" data-value="3">3</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is <a>a</a> different comment 💩 unicode is fun</span
+              >
             </div>
           </div>
-        </td>
-        <td class="lineNum right" data-value="3">3</td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="4"></td>
-        <td class="lineNum left" data-value="4">4</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ga ga</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <textarea data-side="right">test for textarea copying</textarea>
-            </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="3">3</td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="4"></td>
+      <td class="lineNum left" data-value="4">4</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <textarea data-side="right">test for textarea copying</textarea>
           </div>
-        </td>
-        <td class="lineNum right" data-value="4">4</td>
-      </tr>
-      <tr class="not-diff-row">
-        <td class="other">
-          <div class="contentText" data-side="right">some other text</div>
-        </td>
-      </tr>
-    </table>
-  </gr-diff-selection>
-`);
+        </div>
+      </td>
+      <td class="lineNum right" data-value="4">4</td>
+    </tr>
+    <tr class="not-diff-row">
+      <td class="other">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+  </table>
+`;
 
 suite('gr-diff-selection', () => {
   let element: GrDiffSelection;
-  let getCopyEventTargetStub: sinon.SinonStub;
+  let diffTable: HTMLTableElement;
 
   const emulateCopyOn = function (target: HTMLElement | null) {
     const fakeEvent = {
       target,
       preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
       clipboardData: {
         setData: sinon.stub(),
       },
     };
-    getCopyEventTargetStub.returns(target);
-    element._handleCopy(fakeEvent as unknown as ClipboardEvent);
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
     return fakeEvent;
   };
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrDiffSelection;
+  setup(async () => {
+    element = new GrDiffSelection();
+    diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
 
-    getCopyEventTargetStub = sinon.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      diffElement: element.querySelector('#diffTable')!,
-    } as GrDiffBuilderElement;
-    element.diff = {
+    const diff: DiffInfo = {
       ...createDiff(),
       content: [
         {
@@ -136,228 +130,231 @@
         },
       ],
     };
+    element.init(diff, diffTable);
   });
 
   test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    const lineNumberEl = element.querySelector('.lineNum.left');
-    assert.isOk(lineNumberEl);
-    MockInteractions.down(lineNumberEl!);
+    element.diffTable!.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
     assert.isTrue(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'adds selected-left'
     );
     assert.isFalse(
-      element.classList.contains('selected-right'),
+      element.diffTable!.classList.contains('selected-right'),
       'removes selected-right'
     );
   });
 
   test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    const lineNumberEl = element.querySelector('.lineNum.right');
-    assert.isOk(lineNumberEl);
-    MockInteractions.down(lineNumberEl!);
+    element.diffTable!.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
     assert.isTrue(
-      element.classList.contains('selected-right'),
+      element.diffTable!.classList.contains('selected-right'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
 
   test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    sinon
-      .stub(element, '_elementDescendedFromClass')
-      .callsFake((_: Element, className: string) => className === 'blame');
-    MockInteractions.down(element);
+    element.diffTable!.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    element.diffTable!.appendChild(blameDiv);
+    mouseDown(blameDiv);
     assert.isTrue(
-      element.classList.contains('selected-blame'),
+      element.diffTable!.classList.contains('selected-blame'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
 
   test('ignores copy for non-content Element', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
     assert.isFalse(getSelectedTextStub.called);
   });
 
   test('asks for text for left side Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
   });
 
   test('reacts to copy for content Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.isTrue(getSelectedTextStub.called);
   });
 
   test('copy event is prevented for content Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     getSelectedTextStub.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.isTrue(event.preventDefault.called);
   });
 
   test('inserts text into clipboard on copy', () => {
-    sinon.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.deepEqual(
       ['Text', 'the text'],
       event.clipboardData.setData.lastCall.args
     );
   });
 
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
 
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
+    element.setClasses(['selected-blame']);
+    assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
   });
 
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sinon.stub(element.classList, 'add');
-    const removeStub = sinon.stub(element.classList, 'remove').callsFake(() => {
-      assert.isFalse(addStub.called);
-    });
-    element._setClasses(['selected-comment', 'selected-left']);
+  test('setClasses removes before it ads', () => {
+    element.diffTable!.classList.add('selected-right');
+    const addStub = sinon.stub(element.diffTable!.classList, 'add');
+    const removeStub = sinon
+      .stub(element.diffTable!.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
     assert.isTrue(addStub.called);
     assert.isTrue(removeStub.called);
   });
 
   test('copies content correctly', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
 
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
     range.setEnd(
-      element.querySelectorAll('div.contentText')[4]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
       2
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
   });
 
   test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
-      element.querySelector('.gr-formatted-text *')!.firstChild!,
+      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
       3
     );
     range.setEnd(
-      element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
+      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
       7
     );
     selection.addRange(range);
     assert.equal(
       's is a comment\nThis is a differ',
-      element._getSelectedText(Side.LEFT, true)
+      element.getSelectedText(Side.LEFT, true)
     );
   });
 
   test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
+    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
     range.setStart(nodes[2].childNodes[2], 13);
     range.setEnd(nodes[2].childNodes[2], 23);
     selection.addRange(range);
-    assert.equal('mment 💩 u', element._getSelectedText(Side.LEFT, true));
+    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
   });
 
   test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
     assert.isFalse(selectedTextSpy.called);
   });
 
   test('regression test for 4794', () => {
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
+    element.diffTable!.classList.add('selected-right');
+    element.diffTable!.classList.remove('selected-left');
 
     const selection = document.getSelection();
     if (!selection) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
-      element.querySelectorAll('div.contentText')[1]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
       4
     );
     range.setEnd(
-      element.querySelectorAll('div.contentText')[1]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
       10
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.RIGHT, false), ' other');
+    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
   });
 
   test('copies to end of side (issue 7895)', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
     range.setEnd(
-      element.querySelectorAll('div.contentText')[4]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
       2
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
   });
 
-  suite('_getTextContentForRange', () => {
+  suite('getTextContentForRange', () => {
     let selection: Selection;
     let range: Range;
     let nodes: NodeListOf<GrFormattedText>;
 
     setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
+      element.diffTable!.classList.add('selected-left');
+      element.diffTable!.classList.add('selected-comment');
+      element.diffTable!.classList.remove('selected-right');
       const s = document.getSelection();
       if (s === null) assert.fail('no selection');
       selection = s;
       selection.removeAllRanges();
       range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
+      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
     });
 
     test('multi level element contained in range', () => {
@@ -365,7 +362,7 @@
       range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'his is a differ'
       );
     });
@@ -375,7 +372,7 @@
       range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'a differ'
       );
     });
@@ -385,16 +382,9 @@
       range.setEnd(nodes[0].firstChild!, 12);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'is is a co'
       );
     });
   });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: ['test'], right: ['test']};
-    element.diff = createDiff();
-    flush();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index a38ec91..34c2a33 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -80,6 +80,7 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -99,7 +100,6 @@
 
 export interface GrDiff {
   $: {
-    highlights: GrDiffHighlight;
     diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
@@ -294,6 +294,10 @@
 
   private renderDiffTableTask?: DelayedTask;
 
+  private diffSelection = new GrDiffSelection();
+
+  private highlights = new GrDiffHighlight();
+
   constructor() {
     super();
     this._setLoading(true);
@@ -315,6 +319,8 @@
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
     super.disconnectedCallback();
   }
 
@@ -357,7 +363,7 @@
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, false);
+    this.highlights.handleSelectionChange(selection, false);
   };
 
   private readonly handleMouseUp = () => {
@@ -365,7 +371,7 @@
     // mouse-up if there's a selection that just covers a line change. We
     // can't do that on selection change since the user may still be dragging.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, true);
+    this.highlights.handleSelectionChange(selection, true);
   };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
@@ -404,7 +410,7 @@
       const range = getRange(threadEl);
       if (!range) return undefined;
 
-      return {side, range, hovering: false, rootId: threadEl.rootId};
+      return {side, range, rootId: threadEl.rootId};
     }
 
     // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
@@ -430,7 +436,6 @@
       this.push('_commentRanges', {
         side: Side.RIGHT,
         range: this.highlightRange,
-        hovering: true,
         rootId: '',
       });
     }
@@ -498,7 +503,7 @@
   }
 
   isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
+    return !!this.highlights.selectedRange;
   }
 
   toggleLeftDiff() {
@@ -590,7 +595,7 @@
     if (!this.isRangeSelected()) {
       throw Error('Selection is needed for new range comment');
     }
-    const selectedRange = this.$.highlights.selectedRange;
+    const selectedRange = this.highlights.selectedRange;
     if (!selectedRange) throw Error('selected range not set');
     const {side, range} = selectedRange;
     this._createCommentForSelection(side, range);
@@ -813,6 +818,10 @@
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
+    if (this.diff) {
+      this.diffSelection.init(this.diff, this.$.diffTable);
+      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index e05e85a..6d36b89 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -487,6 +487,10 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable {
+      /* for gr-selection-action-box positioning */
+      position: relative;
+    }
     #diffTable:focus {
       outline: none;
     }
@@ -670,6 +674,14 @@
     .token-highlight {
       background-color: var(--token-highlighting-color, #fffd54);
     }
+
+    gr-selection-action-box {
+      /**
+       * Needs z-index to appear above wrapped content, since it's inserted
+       * into DOM before it.
+       */
+      z-index: 10;
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -686,44 +698,36 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-          use-new-image-diff-ui="[[useNewImageDiffUi]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-            contenteditable$="[[isContentEditable]]"
-          ></table>
+    <gr-diff-builder
+      id="diffBuilder"
+      comment-ranges="[[_commentRanges]]"
+      coverage-ranges="[[coverageRanges]]"
+      diff="[[diff]]"
+      path="[[path]]"
+      view-mode="[[viewMode]]"
+      is-image-diff="[[isImageDiff]]"
+      base-image="[[baseImage]]"
+      layers="[[layers]]"
+      revision-image="[[revisionImage]]"
+      use-new-image-diff-ui="[[useNewImageDiffUi]]"
+    >
+      <table
+        id="diffTable"
+        class$="[[_diffTableClass]]"
+        role="presentation"
+        contenteditable$="[[isContentEditable]]"
+      ></table>
 
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
+      <template
+        is="dom-if"
+        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+      >
+        <div class="whitespace-change-only-message">
+          This file only contains whitespace changes. Modify the whitespace
+          setting to see the changes.
+        </div>
+      </template>
+    </gr-diff-builder>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index ba4fa9f..0559811 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -25,6 +25,7 @@
 import {Side} from '../../../api/diff.js';
 import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 import {AbortStop} from '../../../api/core.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -50,21 +51,21 @@
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
+      sinon.stub(element.highlights, 'handleSelectionChange');
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+      assert.isTrue(element.highlights.handleSelectionChange.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+      assert.isFalse(element.highlights.handleSelectionChange.called);
     });
   });
 
@@ -627,6 +628,7 @@
         ab: Array(13).fill('text'),
       }];
       setupSampleDiff({content});
+      await new Promise(resolve => afterNextRender(element, resolve));
 
       element.appendChild(threadEl);
       await flush();
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 6c8a5e9..70cec64 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -20,7 +20,6 @@
 export interface CommentRangeLayer {
   side: Side;
   range: CommentRange;
-  hovering: boolean;
   // New drafts don't have a rootId.
   rootId?: string;
 }
@@ -40,7 +39,6 @@
  * highlights.
  */
 interface CommentRangeLineLayer {
-  hovering: boolean;
   longRange: boolean;
   id: string;
   // start char (0-based)
@@ -59,7 +57,7 @@
 
 const RANGE_BASE_ONLY = 'style-scope gr-diff range';
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
 export class GrRangedCommentLayer implements DiffLayer {
   private knownRanges: CommentRangeLayer[] = [];
@@ -95,11 +93,8 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.id)}`
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
       );
     }
   }
@@ -139,17 +134,15 @@
   }
 
   private addRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     const longRange = isLongCommentRange(range);
     this.updateRangesMap({
       side,
       range,
-      hovering,
-      operation: (forLine, startChar, endChar, hovering) => {
+      operation: (forLine, startChar, endChar) => {
         forLine.push({
           start: startChar,
           end: endChar,
-          hovering,
           id: id(commentRange),
           longRange,
         });
@@ -158,11 +151,10 @@
   }
 
   private removeRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     this.updateRangesMap({
       side,
       range,
-      hovering,
       operation: forLine => {
         const index = forLine.findIndex(
           lineRange => id(commentRange) === lineRange.id
@@ -175,21 +167,19 @@
   private updateRangesMap(options: {
     side: Side;
     range: CommentRange;
-    hovering: boolean;
     operation: (
       forLine: CommentRangeLineLayer[],
       start: number,
-      end: number,
-      hovering: boolean
+      end: number
     ) => void;
   }) {
-    const {side, range, hovering, operation} = options;
+    const {side, range, operation} = options;
     const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
       const start = line === range.start_line ? range.start_character : 0;
       const end = line === range.end_line ? range.end_character : -1;
-      operation(forLine, start, end, hovering);
+      operation(forLine, start, end);
     }
     this.notifyUpdateRange(range.start_line, range.end_line, side);
   }
@@ -199,25 +189,20 @@
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
     if (lineNum === 'FILE' || lineNum === 'LOST') return [];
     const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
-    return (
-      ranges
-        .map(range => {
-          // Make a copy, so that the normalization below does not mess with
-          // our map.
-          range = {...range};
-          range.end = range.end === -1 ? line.text.length : range.end;
+    return ranges.map(range => {
+      // Make a copy, so that the normalization below does not mess with
+      // our map.
+      range = {...range};
+      range.end = range.end === -1 ? line.text.length : range.end;
 
-          // Normalize invalid ranges where the start is after the end but the
-          // start still makes sense. Set the end to the end of the line.
-          // @see Issue 5744
-          if (range.start >= range.end && range.start < line.text.length) {
-            range.end = line.text.length;
-          }
+      // Normalize invalid ranges where the start is after the end but the
+      // start still makes sense. Set the end to the end of the line.
+      // @see Issue 5744
+      if (range.start >= range.end && range.start < line.text.length) {
+        range.end = line.text.length;
+      }
 
-          return range;
-        })
-        // Sort the ranges so that hovering highlights are on top.
-        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
-    );
+      return range;
+    });
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 4e35645..15d14e3 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -24,7 +24,6 @@
     start_line: 36,
   },
   rootId: 'a',
-  hovering: false,
 };
 
 const rangeB: CommentRangeLayer = {
@@ -36,7 +35,6 @@
     start_line: 10,
   },
   rootId: 'b',
-  hovering: false,
 };
 
 const rangeC: CommentRangeLayer = {
@@ -47,7 +45,6 @@
     start_character: 5,
     start_line: 100,
   },
-  hovering: false,
 };
 
 const rangeD: CommentRangeLayer = {
@@ -59,7 +56,6 @@
     start_line: 55,
   },
   rootId: 'd',
-  hovering: false,
 };
 
 const rangeE: CommentRangeLayer = {
@@ -70,7 +66,6 @@
     start_character: 1,
     start_line: 60,
   },
-  hovering: false,
 };
 
 suite('gr-ranged-comment-layer', () => {
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 3ddff60..a1b732f 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -30,6 +30,5 @@
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
-  CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index e99cf27..a83e897 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -138,6 +138,8 @@
       box-sizing: border-box;
       padding: var(--spacing-s);
     }
+    --iron-autogrow-textarea_-_box-sizing: border-box;
+    --iron-autogrow-textarea_-_padding: var(--spacing-s);
   }
   a {
     color: var(--link-color);
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cc292e6..2fffc9a 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -33,6 +33,7 @@
   CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
@@ -652,6 +653,15 @@
   };
 }
 
+export function createRange(): CommentRange {
+  return {
+    start_line: 1,
+    start_character: 0,
+    end_line: 1,
+    end_character: 1,
+  };
+}
+
 export function createComment(
   extra: Partial<CommentInfo | DraftInfo> = {}
 ): CommentInfo {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 0c63de0..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -296,6 +296,19 @@
   element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
 }
 
+export function mouseDown(element: HTMLElement) {
+  const rect = element.getBoundingClientRect();
+  const eventOptions = {
+    bubbles: true,
+    composed: true,
+    clientX: (rect.left + rect.right) / 2,
+    clientY: (rect.top + rect.bottom) / 2,
+    screenX: (rect.left + rect.right) / 2,
+    screenY: (rect.top + rect.bottom) / 2,
+  };
+  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
 export function assertFails(promise: Promise<unknown>, error?: unknown) {
   promise
     .then((_v: unknown) => {