Merge changes I93022cb7,I4b32d082

* changes:
  Rearrange code to avoid dependency on GerritClassLoader internally.
  Revert "Revert "Use custom gerrit class loader to avoid usage of reflection""
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
index 586406f..45da2ba 100755
--- a/Documentation/backend_licenses.txt
+++ b/Documentation/backend_licenses.txt
@@ -1123,7 +1123,6 @@
 * flexmark-ext-footnotes
 * flexmark-ext-gfm-issues
 * flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
 * flexmark-ext-gfm-tasklist
 * flexmark-ext-gfm-users
 * flexmark-ext-ins
@@ -1134,8 +1133,6 @@
 * flexmark-ext-typographic
 * flexmark-ext-wikilink
 * flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
 * flexmark-profile-pegdown
 * flexmark-util
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index cea9400..7864858 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -886,6 +886,7 @@
 +
 If set to 0 the cache is disabled; entries are loaded but not stored
 in-memory.
+
 +
 **NOTE**: When the cache is disabled, there is no locking when accessing
 the same key/value, and therefore multiple threads may
@@ -2439,7 +2440,7 @@
 [gerrit]
   installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
 ----
-+
+
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index f13bc22..97b58af 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -125,7 +125,7 @@
 and link:access-control.html#references_magic[magic refs].
 
 Gerrit only supports tags that are reachable by any ref not owned by
-Gerrit. This includes branches (refs/heads/*) or custom ref namespaces
+Gerrit. This includes branches (refs/heads/\*) or custom ref namespaces
 (refs/my-company/*). Tagging a change ref is not supported.
 When filtering tags by visibility, Gerrit performs a reachability check
 and will present the user ony with tags that are reachable by any ref
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index ab5c255..2499a15 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -1127,7 +1127,6 @@
 * flexmark-ext-footnotes
 * flexmark-ext-gfm-issues
 * flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
 * flexmark-ext-gfm-tasklist
 * flexmark-ext-gfm-users
 * flexmark-ext-ins
@@ -1138,8 +1137,6 @@
 * flexmark-ext-typographic
 * flexmark-ext-wikilink
 * flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
 * flexmark-profile-pegdown
 * flexmark-util
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index af922f1..e8f9a6a 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7060,11 +7060,8 @@
 The user who submitted the change, as an
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
-Whether the calling user has starred this change with the default label.
+Whether the calling user has starred this change.
 Only set if link:#star[requested].
-|`stars`              |optional|
-A list of star labels that are applied by the calling user to this
-change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 53b6c95..6e7ac6b 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -81,7 +81,6 @@
         "//lib:flexmark-ext-footnotes",
         "//lib:flexmark-ext-gfm-issues",
         "//lib:flexmark-ext-gfm-strikethrough",
-        "//lib:flexmark-ext-gfm-tables",
         "//lib:flexmark-ext-gfm-tasklist",
         "//lib:flexmark-ext-gfm-users",
         "//lib:flexmark-ext-ins",
@@ -92,8 +91,6 @@
         "//lib:flexmark-ext-typographic",
         "//lib:flexmark-ext-wikilink",
         "//lib:flexmark-ext-yaml-front-matter",
-        "//lib:flexmark-formatter",
-        "//lib:flexmark-html-parser",
         "//lib:flexmark-profile-pegdown",
         "//lib:flexmark-util",
         "//lib:gson",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 8b41356..df470ec 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,232 +14,68 @@
 
 package com.google.gerrit.server;
 
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toSet;
-
 import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.git.GitUpdateFailureException;
-import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
-import java.util.NavigableSet;
-import java.util.Objects;
 import java.util.Set;
-import java.util.TreeSet;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
-@Singleton
-public class StarredChangesUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+public interface StarredChangesUtil {
   @AutoValue
-  public abstract static class StarField {
+  abstract class StarField {
     private static final String SEPARATOR = ":";
 
     @Nullable
     public static StarField parse(String s) {
+      Integer id;
       int p = s.indexOf(SEPARATOR);
       if (p >= 0) {
-        Integer id = Ints.tryParse(s.substring(0, p));
-        if (id == null) {
-          return null;
-        }
-        Account.Id accountId = Account.id(id);
-        String label = s.substring(p + 1);
-        return create(accountId, label);
+        id = Ints.tryParse(s.substring(0, p));
+      } else {
+        id = Ints.tryParse(s);
       }
-      return null;
+      if (id == null) {
+        return null;
+      }
+      return create(Account.id(id));
     }
 
-    public static StarField create(Account.Id accountId, String label) {
-      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
+    public static StarField create(Account.Id accountId) {
+      return new AutoValue_StarredChangesUtil_StarField(accountId);
     }
 
     public abstract Account.Id accountId();
 
-    public abstract String label();
-
     @Override
     public final String toString() {
-      return accountId() + SEPARATOR + label();
+      return accountId().toString();
     }
   }
 
-  public enum Operation {
-    ADD,
-    REMOVE
-  }
-
-  @AutoValue
-  public abstract static class StarRef {
-    private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
-
-    private static StarRef create(Ref ref, Iterable<String> labels) {
-      return new AutoValue_StarredChangesUtil_StarRef(
-          requireNonNull(ref), ImmutableSortedSet.copyOf(labels));
-    }
-
-    @Nullable
-    public abstract Ref ref();
-
-    public abstract NavigableSet<String> labels();
-
-    public ObjectId objectId() {
-      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
-    }
-  }
-
-  public static class IllegalLabelException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    IllegalLabelException(String message) {
-      super(message);
-    }
-  }
-
-  public static class InvalidLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    InvalidLabelsException(Set<String> invalidLabels) {
-      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-  }
-
-  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    MutuallyExclusiveLabelsException(String label1, String label2) {
-      super(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-  }
-
-  public static final String DEFAULT_LABEL = "star";
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsers;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  StarredChangesUtil(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsers = allUsers;
-    this.serverIdent = serverIdent;
-  }
-
-  public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format(
-              "Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  public void star(Account.Id accountId, Change.Id changeId, Operation op)
-      throws IllegalLabelException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(changeId, accountId);
-      StarRef old = readLabels(repo, refName);
-
-      NavigableSet<String> labels = new TreeSet<>(old.labels());
-      switch (op) {
-        case ADD:
-          labels.add(DEFAULT_LABEL);
-          break;
-        case REMOVE:
-          labels.remove(DEFAULT_LABEL);
-          break;
-      }
-
-      if (labels.isEmpty()) {
-        deleteRef(repo, refName, old.objectId());
-      } else {
-        updateLabels(repo, refName, old.objectId(), labels);
-      }
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
-          e);
-    }
-  }
+  boolean isStarred(Account.Id accountId, Change.Id changeId);
 
   /**
    * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
    * {@code caller} user.
    */
-  public Set<Change.Id> areStarred(
-      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
-    List<String> starRefs =
-        changeIds.stream()
-            .map(c -> RefNames.refsStarredChanges(c, caller))
-            .collect(Collectors.toList());
-    try {
-      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
-          .stream()
-          .map(r -> Change.Id.fromAllUsersRef(r))
-          .collect(Collectors.toSet());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log(
-          "Failed getting starred changes for account %d within changes: %s",
-          caller.get(), Joiner.on(", ").join(changeIds));
-      return ImmutableSet.of();
-    }
-  }
+  Set<Change.Id> areStarred(Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller);
+
+  ImmutableMap<Account.Id, Ref> byChange(Change.Id changeId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges);
+
+  void star(Account.Id accountId, Change.Id changeId);
+
+  void unstar(Account.Id accountId, Change.Id changeId);
 
   /**
    * Unstar the given change for all users.
@@ -250,239 +86,5 @@
    * @param changeId change ID.
    * @throws IOException if an error occurred.
    */
-  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
-      batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent.get());
-      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        String refName = RefNames.refsStarredChanges(changeId, accountId);
-        Ref ref = repo.getRefDatabase().exactRef(refName);
-        if (ref != null) {
-          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
-        }
-      }
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand command : batchUpdate.getCommands()) {
-        if (command.getResult() != ReceiveCommand.Result.OK) {
-          String message =
-              String.format(
-                  "Unstar change %d failed, ref %s could not be deleted: %s",
-                  changeId.get(), command.getRefName(), command.getResult());
-          if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-            throw new LockFailureException(message, batchUpdate);
-          }
-          throw new GitUpdateFailureException(message, batchUpdate);
-        }
-      }
-    }
-  }
-
-  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
-    }
-  }
-
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
-    return byAccountId(accountId, true);
-  }
-
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
-      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
-        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
-        // Skip all refs that don't correspond with accountId.
-        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
-          continue;
-        }
-        // Skip all refs that don't contain the required label.
-        StarRef starRef = readLabels(repo, ref.getName());
-        if (!starRef.labels().contains(DEFAULT_LABEL)) {
-          continue;
-        }
-
-        // Skip invalid change ids.
-        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (skipInvalidChanges && changeId == null) {
-          continue;
-        }
-        builder.add(changeId);
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get starred changes for account %d failed", accountId.get()), e);
-    }
-  }
-
-  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
-      throws IOException {
-    String prefix = RefNames.refsStarredChangesPrefix(changeId);
-    RefDatabase refDb = allUsers.getRefDatabase();
-    return refDb.getRefsByPrefix(prefix).stream()
-        .map(r -> r.getName().substring(prefix.length()))
-        .map(refPart -> Ints.tryParse(refPart))
-        .filter(Objects::nonNull)
-        .map(id -> Account.id(id))
-        .collect(toSet());
-  }
-
-  public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
-      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Getting star object ID for account %d on change %d failed",
-          accountId.get(), changeId.get());
-      return ObjectId.zeroId();
-    }
-  }
-
-  public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
-      Ref ref = repo.exactRef(refName);
-      return readLabels(repo, ref);
-    }
-  }
-
-  public static StarRef readLabels(Repository repo, Ref ref) throws IOException {
-    if (ref == null) {
-      return StarRef.MISSING;
-    }
-    try (TraceTimer traceTimer =
-            TraceContext.newTimer(
-                String.format("Read star labels from %s (without ref lookup)", ref.getName()));
-        ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-      return StarRef.create(
-          ref,
-          Splitter.on(CharMatcher.whitespace())
-              .omitEmptyStrings()
-              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-    }
-  }
-
-  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    validateLabels(labels);
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id =
-          oi.insert(
-              Constants.OBJ_BLOB,
-              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
-      oi.flush();
-      return id;
-    }
-  }
-
-  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
-    if (labels == null) {
-      return;
-    }
-
-    NavigableSet<String> invalidLabels = new TreeSet<>();
-    for (String label : labels) {
-      if (CharMatcher.whitespace().matchesAnyOf(label)) {
-        invalidLabels.add(label);
-      }
-    }
-    if (!invalidLabels.isEmpty()) {
-      throw new InvalidLabelsException(invalidLabels);
-    }
-  }
-
-  private void updateLabels(
-      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    try (TraceTimer traceTimer =
-            TraceContext.newTimer(
-                "Update star labels",
-                Metadata.builder().noteDbRefName(refName).resourceCount(labels.size()).build());
-        RevWalk rw = new RevWalk(repo)) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setForceUpdate(true);
-      u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Update star labels", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.update(rw);
-        switch (result) {
-          case NEW:
-          case FORCED:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(
-                String.format("Update star labels on ref %s failed", refName), u);
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Update star labels on ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
-
-  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
-    if (ObjectId.zeroId().equals(oldObjectId)) {
-      // ref doesn't exist
-      return;
-    }
-
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Delete star labels", Metadata.builder().noteDbRefName(refName).build())) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setForceUpdate(true);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Unstar change", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.delete();
-        switch (result) {
-          case FORCED:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
-          case NEW:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Delete star ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
+  void unstarAllForChangeDeletion(Change.Id changeId) throws IOException;
 }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index dc8a7dc..52230ba 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -145,7 +145,6 @@
     copy.revertOf = changeInfo.revertOf;
     copy.submissionId = changeInfo.submissionId;
     copy.starred = changeInfo.starred;
-    copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
     copy.submitter = changeInfo.submitter;
     copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8d42fcc..e41566c 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -693,10 +693,8 @@
     }
 
     if (user.isIdentifiedUser()) {
-      Collection<String> stars = cd.stars(user.getAccountId());
-      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
-      if (!stars.isEmpty()) {
-        out.stars = stars;
+      if (cd.isStarred(user.getAccountId())) {
+        out.starred = true;
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index c5c0be0..234cd2e 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -237,7 +237,7 @@
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
-        h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
+        h.putBoolean(starredChangesUtil.isStarred(user.getAccountId(), getId()));
       }
       prepareETag(h, user);
       return h.hash().toString();
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 0e911b9..22c6995 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.documentation;
 
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.ALL;
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.HARDWRAPS;
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.SUPPRESS_ALL_HTML;
+import static com.vladsch.flexmark.parser.PegdownExtensions.ALL;
+import static com.vladsch.flexmark.parser.PegdownExtensions.HARDWRAPS;
+import static com.vladsch.flexmark.parser.PegdownExtensions.SUPPRESS_ALL_HTML;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
@@ -26,7 +26,7 @@
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter;
 import com.vladsch.flexmark.util.ast.Block;
 import com.vladsch.flexmark.util.ast.Node;
 import com.vladsch.flexmark.util.data.MutableDataHolder;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 1875b64..04d2e5b 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -23,10 +23,9 @@
 import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
 import com.vladsch.flexmark.html.renderer.NodeRenderer;
 import com.vladsch.flexmark.html.renderer.NodeRendererContext;
-import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
-import com.vladsch.flexmark.profiles.pegdown.Extensions;
-import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.profile.pegdown.Extensions;
+import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter;
 import com.vladsch.flexmark.util.ast.Node;
 import com.vladsch.flexmark.util.data.DataHolder;
 import com.vladsch.flexmark.util.data.MutableDataHolder;
@@ -124,8 +123,8 @@
       }
 
       @Override
-      public Set<Class<? extends NodeRendererFactory>> getDelegates() {
-        Set<Class<? extends NodeRendererFactory>> delegates = new HashSet<>();
+      public Set<Class<?>> getDelegates() {
+        Set<Class<?>> delegates = new HashSet<>();
         delegates.add(AnchorLinkNodeRenderer.Factory.class);
         return delegates;
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 7057ff7..eccd199 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -1267,21 +1266,20 @@
   public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
       COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
 
-  /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
+  /** Star labels on this change in the format: &lt;account-id&gt; */
   public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
       IndexedField.<ChangeData>iterableStringBuilder("Star")
           .stored()
           .build(
               cd ->
                   Iterables.transform(
-                      cd.stars().entries(),
-                      e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
+                      cd.stars(),
+                      accountId -> StarredChangesUtil.StarField.create(accountId).toString()),
               (cd, field) ->
                   cd.setStars(
                       StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> StarredChangesUtil.StarField.parse(f))
-                          .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
+                          .map(f -> StarredChangesUtil.StarField.parse(f).accountId())
+                          .collect(toImmutableList())));
 
   public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
       STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
@@ -1289,7 +1287,7 @@
   /** Users that have starred the change with any label. */
   public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
       IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
-          .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+          .build(cd -> Iterables.transform(cd.stars(), Account.Id::get));
 
   public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
       STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index bf6ed0c..d4b9b08 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -19,7 +19,6 @@
 
 import com.google.auto.factory.AutoFactory;
 import com.google.auto.factory.Provided;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -40,7 +39,6 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
@@ -58,9 +56,9 @@
 import java.io.IOException;
 import java.text.MessageFormat;
 import java.time.Instant;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -90,7 +88,7 @@
 
   // Available after init or after being explicitly set.
   private OutgoingEmail email;
-  private ListMultimap<Account.Id, String> stars;
+  private List<Account.Id> stars;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
   private String changeMessage;
@@ -414,11 +412,7 @@
       return;
     }
 
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        email.addByAccountId(RecipientType.BCC, e.getKey());
-      }
-    }
+    stars.forEach(accountId -> email.addByAccountId(RecipientType.BCC, accountId));
   }
 
   /** Include users and groups that want notification of events. */
diff --git a/java/com/google/gerrit/server/notedb/NoteDbModule.java b/java/com/google/gerrit/server/notedb/NoteDbModule.java
index d8a5fd5..9323178 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -17,6 +17,8 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 
@@ -44,6 +46,7 @@
     factory(NoteDbUpdateManager.Factory.class);
     factory(RobotCommentNotes.Factory.class);
     factory(RobotCommentUpdate.Factory.class);
+    bind(StarredChangesUtil.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
 
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
diff --git a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
new file mode 100644
index 0000000..7dc8f59
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
@@ -0,0 +1,332 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.GitUpdateFailureException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+@Singleton
+public class StarredChangesUtilNoteDbImpl implements StarredChangesUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String DEFAULT_STAR_LABEL = "star";
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final AllUsersName allUsers;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  StarredChangesUtilNoteDbImpl(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllUsersName allUsers,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
+    this.allUsers = allUsers;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  public boolean isStarred(Account.Id accountId, Change.Id changeId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId)).isPresent();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Reading stars from change %d for account %d failed",
+              changeId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  @Override
+  public void star(Account.Id accountId, Change.Id changeId) {
+    updateStar(accountId, changeId, true);
+  }
+
+  @Override
+  public void unstar(Account.Id accountId, Change.Id changeId) {
+    updateStar(accountId, changeId, false);
+  }
+
+  private void updateStar(Account.Id accountId, Change.Id changeId, boolean shouldAdd) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsStarredChanges(changeId, accountId);
+      if (shouldAdd) {
+        addRef(repo, refName, null);
+      } else {
+        Optional<Ref> ref = getStarRef(repo, refName);
+        if (ref.isPresent()) {
+          deleteRef(repo, refName, ref.get().getObjectId());
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  @Override
+  public Set<Change.Id> areStarred(
+      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+    List<String> starRefs =
+        changeIds.stream()
+            .map(c -> RefNames.refsStarredChanges(c, caller))
+            .collect(Collectors.toList());
+    try {
+      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
+          .stream()
+          .map(r -> Change.Id.fromAllUsersRef(r))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed getting starred changes for account %d within changes: %s",
+          caller.get(), Joiner.on(", ").join(changeIds));
+      return ImmutableSet.of();
+    }
+  }
+
+  @Override
+  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAllowNonFastForwards(true);
+      batchUpdate.setRefLogIdent(serverIdent.get());
+      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
+      for (Account.Id accountId : getStars(repo, changeId)) {
+        String refName = RefNames.refsStarredChanges(changeId, accountId);
+        Ref ref = repo.getRefDatabase().exactRef(refName);
+        if (ref != null) {
+          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
+        }
+      }
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() != ReceiveCommand.Result.OK) {
+          String message =
+              String.format(
+                  "Unstar change %d failed, ref %s could not be deleted: %s",
+                  changeId.get(), command.getRefName(), command.getResult());
+          if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+            throw new LockFailureException(message, batchUpdate);
+          }
+          throw new GitUpdateFailureException(message, batchUpdate);
+        }
+      }
+    }
+  }
+
+  @Override
+  public ImmutableMap<Account.Id, Ref> byChange(Change.Id changeId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableMap.Builder<Account.Id, Ref> builder = ImmutableMap.builder();
+      for (Account.Id accountId : getStars(repo, changeId)) {
+        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId));
+        if (starRef.isPresent()) {
+          builder.put(accountId, starRef.get());
+        }
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get accounts that starred change %d failed", changeId.get()), e);
+    }
+  }
+
+  @Override
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
+    return byAccountId(accountId, true);
+  }
+
+  @Override
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
+        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
+        // Skip all refs that don't correspond with accountId.
+        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
+          continue;
+        }
+
+        // Skip invalid change ids.
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (skipInvalidChanges && changeId == null) {
+          continue;
+        }
+        builder.add(changeId);
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get starred changes for account %d failed", accountId.get()), e);
+    }
+  }
+
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    RefDatabase refDb = allUsers.getRefDatabase();
+    return refDb.getRefsByPrefix(prefix).stream()
+        .map(r -> r.getName().substring(prefix.length()))
+        .map(refPart -> Ints.tryParse(refPart))
+        .filter(Objects::nonNull)
+        .map(id -> Account.id(id))
+        .collect(toSet());
+  }
+
+  private static Optional<Ref> getStarRef(Repository repo, @Nullable String refName)
+      throws IOException {
+    if (refName == null) {
+      return Optional.empty();
+    }
+    Ref ref = repo.exactRef(refName);
+    return Optional.ofNullable(ref);
+  }
+
+  private static ObjectId writeStarredRefContent(Repository repo) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id = oi.insert(Constants.OBJ_BLOB, DEFAULT_STAR_LABEL.getBytes(UTF_8));
+      oi.flush();
+      return id;
+    }
+  }
+
+  private void addRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Add star ref",
+                Metadata.builder().noteDbRefName(refName).resourceCount(1).build());
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setForceUpdate(true);
+      u.setNewObjectId(writeStarredRefContent(repo));
+      u.setRefLogIdent(serverIdent.get());
+      u.setRefLogMessage("Add star ref", true);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case NEW:
+          case FORCED:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                String.format("Add star ref on ref %s failed", refName), u);
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Add star ref on ref %s failed: %s", refName, result.name()));
+        }
+      }
+    }
+  }
+
+  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
+    if (ObjectId.zeroId().equals(oldObjectId)) {
+      // ref doesn't exist
+      return;
+    }
+
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Delete star ref", Metadata.builder().noteDbRefName(refName).build())) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setForceUpdate(true);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setRefLogIdent(serverIdent.get());
+      u.setRefLogMessage("Unstar change", true);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.delete();
+        switch (result) {
+          case FORCED:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
+          case NEW:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Delete star ref %s failed: %s", refName, result.name()));
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 854897f6..638fea9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -68,7 +67,6 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.StarRef;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
@@ -397,9 +395,9 @@
    */
   private Map<Account.Id, ObjectId> draftsByUser;
 
-  private ImmutableListMultimap<Account.Id, String> stars;
-  private StarsOf starsOf;
-  private ImmutableMap<Account.Id, StarRef> starRefs;
+  private ImmutableList<Account.Id> stars;
+  private Account.Id starredBy;
+  private ImmutableMap<Account.Id, Ref> starRefs;
   private ReviewerSet reviewers;
   private ReviewerByEmailSet reviewersByEmail;
   private ReviewerSet pendingReviewers;
@@ -1265,25 +1263,21 @@
     return customKeyedValues;
   }
 
-  public ImmutableListMultimap<Account.Id, String> stars() {
+  public ImmutableList<Account.Id> stars() {
     if (stars == null) {
       if (!lazyload()) {
-        return ImmutableListMultimap.of();
+        return ImmutableList.of();
       }
-      ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
-      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
-        b.putAll(e.getKey(), e.getValue().labels());
-      }
-      return b.build();
+      return starRefs().keySet().asList();
     }
     return stars;
   }
 
-  public void setStars(ListMultimap<Account.Id, String> stars) {
-    this.stars = ImmutableListMultimap.copyOf(stars);
+  public void setStars(List<Account.Id> accountIds) {
+    this.stars = ImmutableList.copyOf(accountIds);
   }
 
-  private ImmutableMap<Account.Id, StarRef> starRefs() {
+  private ImmutableMap<Account.Id, Ref> starRefs() {
     if (starRefs == null) {
       if (!lazyload()) {
         return ImmutableMap.of();
@@ -1293,23 +1287,25 @@
     return starRefs;
   }
 
-  public Set<String> stars(Account.Id accountId) {
-    if (starsOf != null) {
-      if (!starsOf.accountId().equals(accountId)) {
-        starsOf = null;
+  public boolean isStarred(Account.Id accountId) {
+    if (starredBy != null) {
+      if (!starredBy.equals(accountId)) {
+        starredBy = null;
       }
     }
-    if (starsOf == null) {
-      if (stars != null) {
-        starsOf = StarsOf.create(accountId, stars.get(accountId));
+    if (starredBy == null) {
+      if (stars != null && stars.contains(accountId)) {
+        starredBy = accountId;
       } else {
         if (!lazyload()) {
-          return ImmutableSet.of();
+          return false;
         }
-        starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
+        if (starredChangesUtil.isStarred(accountId, legacyId)) {
+          starredBy = accountId;
+        }
       }
     }
-    return starsOf.stars();
+    return starredBy != null;
   }
 
   /**
@@ -1413,17 +1409,6 @@
     public abstract Instant ts();
   }
 
-  @AutoValue
-  abstract static class StarsOf {
-    private static StarsOf create(Account.Id accountId, Iterable<String> stars) {
-      return new AutoValue_ChangeData_StarsOf(accountId, ImmutableSortedSet.copyOf(stars));
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract ImmutableSortedSet<String> stars();
-  }
-
   private Map<Account.Id, ObjectId> draftRefs() {
     if (draftsByUser == null) {
       if (!lazyload()) {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
index c5cbd62..9b4c0a6 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
@@ -159,12 +159,10 @@
     user.getUserName().ifPresent(sshKeyCache::evict);
   }
 
-  private void deleteStarredChanges(Account.Id accountId)
-      throws StarredChangesUtil.IllegalLabelException {
+  private void deleteStarredChanges(Account.Id accountId) {
     ImmutableSet<Change.Id> staredChanges = starredChangesUtil.byAccountId(accountId, false);
     for (Change.Id change : staredChanges) {
-      starredChangesUtil.star(
-          self.get().getAccountId(), change, StarredChangesUtil.Operation.REMOVE);
+      starredChangesUtil.unstar(self.get().getAccountId(), change);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 173f24b..08b9bac 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -36,8 +34,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -72,9 +68,7 @@
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesUtil
-        .getLabels(user.getAccountId(), change.getId())
-        .contains(StarredChangesUtil.DEFAULT_LABEL)) {
+    if (starredChangesUtil.isStarred(user.getAccountId(), change.getId())) {
       return new AccountResource.StarredChange(user, change);
     }
     throw new ResourceNotFoundException(id);
@@ -130,12 +124,7 @@
       }
 
       try {
-        starredChangesUtil.star(
-            self.get().getAccountId(), change.getId(), StarredChangesUtil.Operation.ADD);
-      } catch (MutuallyExclusiveLabelsException e) {
-        throw new ResourceConflictException(e.getMessage());
-      } catch (IllegalLabelException e) {
-        throw new BadRequestException(e.getMessage());
+        starredChangesUtil.star(self.get().getAccountId(), change.getId());
       } catch (DuplicateKeyException e) {
         return Response.none();
       }
@@ -174,12 +163,11 @@
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
-        throws AuthException, IOException, IllegalLabelException {
+        throws AuthException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
-      starredChangesUtil.star(
-          self.get().getAccountId(), rsrc.getChange().getId(), StarredChangesUtil.Operation.REMOVE);
+      starredChangesUtil.unstar(self.get().getAccountId(), rsrc.getChange().getId());
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
index 5d2cb35..a8c11df 100644
--- a/java/com/google/gerrit/server/update/context/RefUpdateContext.java
+++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.UsedAt;
 import java.util.ArrayDeque;
 import java.util.Deque;
 import java.util.Optional;
@@ -131,11 +130,12 @@
     checkArgument(
         updateType != RefUpdateType.DIRECT_PUSH,
         "openDirectPush method with justification must be used to open DIRECT_PUSH context.");
-    return open(new RefUpdateContext(updateType));
+    return open(new RefUpdateContext(updateType, Optional.empty()));
   }
 
-  public static DirectPushRefUpdateContext openDirectPush(Optional<String> justification) {
-    return open(new DirectPushRefUpdateContext(justification));
+  /** Opens a direct push context with an optional justification. */
+  public static RefUpdateContext openDirectPush(Optional<String> justification) {
+    return open(new RefUpdateContext(RefUpdateType.DIRECT_PUSH, justification));
   }
 
   /** Returns the list of opened contexts; the first element is the outermost context. */
@@ -149,13 +149,15 @@
   }
 
   private final RefUpdateType updateType;
+  private final Optional<String> justification;
 
-  private RefUpdateContext(RefUpdateType updateType) {
+  private RefUpdateContext(RefUpdateType updateType, Optional<String> justification) {
     this.updateType = updateType;
+    this.justification = justification;
   }
 
-  protected RefUpdateContext() {
-    this(RefUpdateType.OTHER);
+  protected RefUpdateContext(Optional<String> justification) {
+    this(RefUpdateType.OTHER, justification);
   }
 
   protected static final Deque<RefUpdateContext> getCurrent() {
@@ -177,6 +179,11 @@
     return updateType;
   }
 
+  /** Returns the justification for the operation. */
+  public final Optional<String> getJustification() {
+    return justification;
+  }
+
   /** Closes the current context. */
   @Override
   public void close() {
@@ -185,18 +192,4 @@
         openedContexts.peekLast() == this, "The current context is different from this context.");
     openedContexts.removeLast();
   }
-
-  public static class DirectPushRefUpdateContext extends RefUpdateContext {
-    private final Optional<String> justification;
-
-    private DirectPushRefUpdateContext(Optional<String> justification) {
-      super(RefUpdateType.DIRECT_PUSH);
-      this.justification = justification;
-    }
-
-    @UsedAt(UsedAt.Project.GOOGLE)
-    public Optional<String> getJustification() {
-      return justification;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 81a6443..7466c67 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -8,10 +8,12 @@
         exclude = [
             "AssertableExecutorService.java",
             "TestActionRefUpdateContext.java",
+            "GerritJUnit.java",
         ],
     ),
     visibility = ["//visibility:public"],
     exports = [
+        ":gerrit-junit",
         "//lib:junit",
         "//lib/mockito",
     ],
@@ -61,6 +63,12 @@
 )
 
 java_library(
+    name = "gerrit-junit",
+    srcs = ["GerritJUnit.java"],
+    visibility = ["//visibility:public"],
+)
+
+java_library(
     # This can't be part of gerrit-test-util because of https://github.com/google/guava/issues/2837
     name = "assertable-executor",
     testonly = True,
diff --git a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
index 23ec9aa..e3f6dcf 100644
--- a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
+++ b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
@@ -16,6 +16,7 @@
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.update.context.RefUpdateContext;
+import java.util.Optional;
 
 /**
  * Marks ref updates as a test actions.
@@ -62,6 +63,10 @@
     }
   }
 
+  public TestActionRefUpdateContext() {
+    super(Optional.empty());
+  }
+
   public interface CallableWithException<V, E extends Exception> {
     V call() throws E;
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 6360642..0fb7f00 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -32,7 +32,6 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
-import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
 import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
@@ -798,7 +797,6 @@
       gApi.accounts().self().starChange(triplet);
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
-      assertThat(change.stars).contains(DEFAULT_LABEL);
       refUpdateCounter.assertRefUpdateFor(
           RefUpdateCounter.projectRef(
               allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
@@ -806,7 +804,6 @@
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
-      assertThat(change.stars).isNull();
       refUpdateCounter.assertRefUpdateFor(
           RefUpdateCounter.projectRef(
               allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ec474f1..7fe7635 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -48,7 +48,6 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
@@ -4301,14 +4300,12 @@
       gApi.accounts().self().starChange(triplet);
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
-      assertThat(change.stars).contains(DEFAULT_LABEL);
       // change was not re-indexed
       changeIndexedCounter.assertReindexOf(change, 0);
 
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
-      assertThat(change.stars).isNull();
       // change was not re-indexed
       changeIndexedCounter.assertReindexOf(change, 0);
     }
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
index 5124e2b..e802604 100644
--- a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.update.context.RefUpdateContext.DirectPushRefUpdateContext;
 import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.gerrit.testing.RefUpdateContextCollector;
 import java.util.Map.Entry;
@@ -49,7 +48,7 @@
     assertThat(ctxts).hasSize(1);
     RefUpdateContext ctx = ctxts.get(0);
     assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
-    assertThat(((DirectPushRefUpdateContext) ctx).getJustification()).isEmpty();
+    assertThat(ctx.getJustification()).isEmpty();
   }
 
   @Test
@@ -69,7 +68,6 @@
     assertThat(ctxts).hasSize(1);
     RefUpdateContext ctx = ctxts.get(0);
     assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
-    assertThat(((DirectPushRefUpdateContext) ctx).getJustification())
-        .hasValue("test justification");
+    assertThat(ctx.getJustification()).hasValue("test justification");
   }
 }
diff --git a/javatests/com/google/gerrit/proto/BUILD b/javatests/com/google/gerrit/proto/BUILD
index 6b98b72..216ef94 100644
--- a/javatests/com/google/gerrit/proto/BUILD
+++ b/javatests/com/google/gerrit/proto/BUILD
@@ -5,7 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:gerrit-junit",
         "//lib:protobuf",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
index 02ab0e4..0646669 100644
--- a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -24,7 +24,6 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.update.context.RefUpdateContext.DirectPushRefUpdateContext;
 import java.util.Optional;
 import org.junit.After;
 import org.junit.Test;
@@ -107,8 +106,7 @@
       ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
       assertThat(openedContexts).hasSize(1);
       assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(DIRECT_PUSH);
-      assertThat(((DirectPushRefUpdateContext) openedContexts.get(0)).getJustification())
-          .hasValue("Open in test");
+      assertThat(openedContexts.get(0).getJustification()).hasValue("Open in test");
       assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isTrue();
       assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
     }
@@ -123,7 +121,7 @@
       ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
       assertThat(openedContexts).hasSize(1);
       assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(DIRECT_PUSH);
-      assertThat(((DirectPushRefUpdateContext) openedContexts.get(0)).getJustification()).isEmpty();
+      assertThat(openedContexts.get(0).getJustification()).isEmpty();
       assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isTrue();
       assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
     }
diff --git a/lib/BUILD b/lib/BUILD
index 7aa9a45..96a5325 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -273,19 +273,6 @@
     data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
     exports = ["@flexmark-ext-gfm-strikethrough//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-tables",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-tables",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-tables//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-tasklist",
-    ],
 )
 
 java_library(
@@ -383,29 +370,6 @@
     data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
     exports = ["@flexmark-ext-yaml-front-matter//jar"],
-    runtime_deps = [
-        ":flexmark-formatter",
-    ],
-)
-
-java_library(
-    name = "flexmark-formatter",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-formatter//jar"],
-    runtime_deps = [
-        ":flexmark-html-parser",
-    ],
-)
-
-java_library(
-    name = "flexmark-html-parser",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-html-parser//jar"],
-    runtime_deps = [
-        ":flexmark-profile-pegdown",
-    ],
 )
 
 java_library(
@@ -422,7 +386,15 @@
     name = "flexmark-util",
     data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@flexmark-util//jar"],
+    exports = [
+        "@flexmark-util-ast//jar",
+        "@flexmark-util-builder//jar",
+        "@flexmark-util-data//jar",
+        "@flexmark-util-html//jar",
+        "@flexmark-util-misc//jar",
+        "@flexmark-util-sequence//jar",
+        "@flexmark-util-visitor//jar",
+    ],
 )
 
 java_library(
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 5b6019e..db69fcc 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -376,7 +376,6 @@
   submitted?: Timestamp;
   submitter?: AccountInfo;
   starred?: boolean; // not set if false
-  stars?: StarLabel[];
   submit_type?: SubmitType;
   mergeable?: boolean;
   submittable?: boolean;
@@ -1033,7 +1032,6 @@
  */
 export type SshdInfo = {};
 
-export type StarLabel = BrandType<string, '_startLabel'>;
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
 // where "'ffffffffff'" represents nanoseconds.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 0b14f4f..93cb124 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -1259,12 +1259,9 @@
   private goToEditFile() {
     assertIsDefined(this.path, 'path');
 
-    // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.cursor?.getAddress();
-    this.getChangeModel().navigateToEdit({
-      path: this.path,
-      lineNum: cursorAddress?.number,
-    });
+    const lineNumber = this.cursor?.getTargetLineNumber();
+    const lineNum = typeof lineNumber === 'number' ? lineNumber : undefined;
+    this.getChangeModel().navigateToEdit({path: this.path, lineNum});
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 6896ca8..c085953 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -806,9 +806,7 @@
       element.patchNum = 1 as RevisionPatchSetNum;
       element.basePatchNum = PARENT;
       assertIsDefined(element.cursor);
-      sinon
-        .stub(element.cursor, 'getAddress')
-        .returns({number: lineNumber, leftSide: false});
+      sinon.stub(element.cursor, 'getTargetLineNumber').returns(lineNumber);
       await element.updateComplete;
       const editBtn = queryAndAssert<GrButton>(
         element,
@@ -818,7 +816,7 @@
       editBtn.click();
       assert.equal(navToEditStub.callCount, 1);
       assert.deepEqual(navToEditStub.lastCall.args, [
-        {path: 't.txt', lineNum: 42},
+        {path: 't.txt', lineNum: lineNumber},
       ]);
     });
 
@@ -1428,9 +1426,6 @@
     test('onLineSelected', () => {
       const replaceStateStub = sinon.stub(history, 'replaceState');
       assertIsDefined(element.cursor);
-      sinon
-        .stub(element.cursor, 'getAddress')
-        .returns({number: 123, leftSide: false});
 
       element.changeNum = 321 as NumericChangeId;
       element.change = {
@@ -1450,9 +1445,6 @@
     test('line selected on left side', () => {
       const replaceStateStub = sinon.stub(history, 'replaceState');
       assertIsDefined(element.cursor);
-      sinon
-        .stub(element.cursor, 'getAddress')
-        .returns({number: 123, leftSide: true});
 
       element.changeNum = 321 as NumericChangeId;
       element.change = {
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts
index 6a32afb..3d0e507 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts
@@ -375,6 +375,10 @@
     }
   }
 
+  getTargetLineNumber(): LineNumber | undefined {
+    return this.getAddress()?.number;
+  }
+
   /**
    * Get an object describing the location of the cursor. Such as
    * {leftSide: false, number: 123} for line 123 of the revision, or
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
index 0da3522..45a8784 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
@@ -25,6 +25,8 @@
   getResponsiveMode,
   isResponsive,
   isNewDiff,
+  getDataFromCommentThreadEl,
+  GrDiffCommentThread,
 } from '../../diff/gr-diff/gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -564,10 +566,12 @@
     threadEl: GrDiffThreadElement
   ) {
     hoverEl.addEventListener('mouseenter', () => {
-      fire(threadEl, 'comment-thread-mouseenter', {});
+      const data = getDataFromCommentThreadEl(threadEl);
+      if (data) fire(threadEl, 'comment-thread-mouseenter', data);
     });
     hoverEl.addEventListener('mouseleave', () => {
-      fire(threadEl, 'comment-thread-mouseleave', {});
+      const data = getDataFromCommentThreadEl(threadEl);
+      if (data) fire(threadEl, 'comment-thread-mouseleave', data);
     });
   }
 
@@ -1119,8 +1123,8 @@
     'gr-diff': LitElement;
   }
   interface HTMLElementEventMap {
-    'comment-thread-mouseenter': CustomEvent<{}>;
-    'comment-thread-mouseleave': CustomEvent<{}>;
+    'comment-thread-mouseenter': CustomEvent<GrDiffCommentThread>;
+    'comment-thread-mouseleave': CustomEvent<GrDiffCommentThread>;
     'loading-changed': ValueChangedEvent<boolean>;
     'render-required': CustomEvent<{}>;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
index d936126..0962de7 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -17,7 +17,7 @@
   let element: GrContextControlsSection;
 
   setup(async () => {
-    const diffModel = new DiffModel();
+    const diffModel = new DiffModel(document);
     element = (
       await fixture<DIProviderElement>(
         wrapInProvider(
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 0ac7516..8ef2f82 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -3,7 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit';
 import {property, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createRef, Ref, ref} from 'lit/directives/ref.js';
@@ -20,10 +20,25 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {getBaseUrl} from '../../../utils/url-util';
+import {otherSide} from '../../../utils/diff-util';
 import './gr-diff-text';
+import {
+  diffClasses,
+  GrDiffCommentThread,
+  isLongCommentRange,
+  isNewDiff,
+  isResponsive,
+} from '../gr-diff/gr-diff-utils';
+import {resolve} from '../../../models/dependency';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {when} from 'lit/directives/when.js';
+import {isDefined} from '../../../types/types';
+import {BehaviorSubject, combineLatest} from 'rxjs';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {diffClasses, isNewDiff, isResponsive} from '../gr-diff/gr-diff-utils';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {deepEqual} from '../../../utils/deep-util';
+import {subscribe} from '../../../elements/lit/subscription-controller';
 
 export class GrDiffRow extends LitElement {
   contentLeftRef: Ref<LitElement> = createRef();
@@ -45,9 +60,13 @@
   @property({type: Object})
   left?: GrDiffLine;
 
+  private left$ = new BehaviorSubject<GrDiffLine | undefined>(undefined);
+
   @property({type: Object})
   right?: GrDiffLine;
 
+  private right$ = new BehaviorSubject<GrDiffLine | undefined>(undefined);
+
   @property({type: Object})
   blameInfo?: BlameInfo;
 
@@ -78,8 +97,11 @@
    * running such tests the render() method has to wrap the DOM in a proper
    * <table> element.
    */
-  @state()
-  addTableWrapperForTesting = false;
+  @state() addTableWrapperForTesting = false;
+
+  @state() leftComments: GrDiffCommentThread[] = [];
+
+  @state() rightComments: GrDiffCommentThread[] = [];
 
   /**
    * Keeps track of whether diff layers have already been applied to the diff
@@ -93,6 +115,46 @@
    */
   private layersApplied = false;
 
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () =>
+        combineLatest([this.left$, this.getDiffModel().comments$]).pipe(
+          map(([left, comments]) =>
+            comments.filter(
+              c =>
+                c.line === left?.lineNumber(Side.LEFT) && c.side === Side.LEFT
+            )
+          ),
+          distinctUntilChanged(deepEqual)
+        ),
+      leftComments => (this.leftComments = leftComments)
+    );
+    subscribe(
+      this,
+      () =>
+        combineLatest([this.right$, this.getDiffModel().comments$]).pipe(
+          map(([right, comments]) =>
+            comments.filter(
+              c =>
+                c.line === right?.lineNumber(Side.RIGHT) &&
+                c.side === Side.RIGHT
+            )
+          ),
+          distinctUntilChanged(deepEqual)
+        ),
+      rightComments => (this.rightComments = rightComments)
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('left')) this.left$.next(this.left);
+    if (changedProperties.has('right')) this.right$.next(this.right);
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -296,12 +358,13 @@
         data-value=${lineNumber}
         aria-label=${ifDefined(
           this.computeLineNumberAriaLabel(line, lineNumber)
-        )}
+    )}
+        @click=${() => this.getDiffModel().createCommentOnLine(lineNumber, side)}
         @mouseenter=${() =>
           fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
         @mouseleave=${() =>
           fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
-      >${lineNumber === FILE ? 'File' : lineNumber.toString()}</button>
+      >${lineNumber === FILE ? 'FILE' : lineNumber.toString()}</button>
     `;
   }
 
@@ -352,6 +415,11 @@
       <td
         ${ref(this.contentCellRef(side))}
         class=${diffClasses(...extras)}
+        @click=${() => {
+          if (lineNumber) {
+            this.getDiffModel().selectLine(lineNumber, side);
+          }
+        }}
         @mouseenter=${() => {
           if (lineNumber)
             fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
@@ -360,7 +428,7 @@
           if (lineNumber)
             fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
         }}
-      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+      >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}</td>
     `;
   }
 
@@ -381,21 +449,53 @@
     return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
   }
 
+  private renderLostMessage(side: Side) {
+    if (this.lineNumber(side) !== LOST) return nothing;
+    if (this.getComments(side).length === 0) return nothing;
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div class="lost-message"
+      ><gr-icon icon="info"></gr-icon
+      ><span>Original comment position not found in this patchset</span
+    ></div>`;
+  }
+
   private renderThreadGroup(side: Side) {
-    const lineNumber = this.lineNumber(side);
-    if (!lineNumber) return nothing;
+    if (!this.lineNumber(side)) return nothing;
+
+    if (
+      this.getComments(side).length === 0 &&
+      (!this.unifiedDiff || this.getComments(otherSide(side)).length === 0)
+    ) {
+      return nothing;
+    }
     return html`<div class="thread-group" data-side=${side}>
-      <slot name="${side}-${lineNumber}"></slot>
-      ${this.renderSecondSlot()}
+      ${this.renderSlot(side)}
+      ${when(this.unifiedDiff, () => this.renderSlot(otherSide(side)))}
     </div>`;
   }
 
-  private renderSecondSlot() {
-    if (!this.unifiedDiff) return nothing;
-    if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
-    return html`<slot
-      name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
-    ></slot>`;
+  private renderSlot(side: Side) {
+    const line = this.lineNumber(side);
+    if (!line) return nothing;
+    if (this.getComments(side).length === 0) return nothing;
+    return html`
+      ${this.renderRangedCommentHints(side)}
+      <slot name="${side}-${line}"></slot>
+    `;
+  }
+
+  private renderRangedCommentHints(side: Side) {
+    const ranges = this.getComments(side)
+      .map(c => c.range)
+      .filter(isDefined)
+      .filter(isLongCommentRange);
+    return ranges.map(
+      range =>
+        html`
+          <gr-ranged-comment-hint .range=${range}></gr-ranged-comment-hint>
+        `
+    );
   }
 
   private contentRef(side: Side) {
@@ -414,14 +514,18 @@
       : this.lineNumberRightRef;
   }
 
-  private lineNumber(side: Side) {
+  lineNumber(side: Side) {
     return this.line(side)?.lineNumber(side);
   }
 
-  private line(side: Side) {
+  line(side: Side) {
     return side === Side.LEFT ? this.left : this.right;
   }
 
+  private getComments(side: Side) {
+    return side === Side.LEFT ? this.leftComments : this.rightComments;
+  }
+
   private getType(side?: Side): string | undefined {
     if (this.unifiedDiff) return undefined;
     if (side === Side.LEFT) return this.left?.type;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 42d30aa..61ea055 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -58,8 +58,6 @@
                 >
                   <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="left">
-                  <slot name="left-1"> </slot>
                 </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
@@ -82,9 +80,6 @@
                 >
                   <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                </div>
               </td>
             </tr>
             <slot name="post-left-line-1"></slot>
@@ -143,10 +138,6 @@
                 >
                   <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                  <slot name="left-1"> </slot>
-                </div>
               </td>
             </tr>
             <slot name="post-left-line-1"></slot>
@@ -201,9 +192,6 @@
                 >
                   <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                </div>
               </td>
               <slot name="post-right-line-1"></slot>
             </tr>
@@ -252,9 +240,6 @@
                 >
                   <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="left">
-                  <slot name="left-1"> </slot>
-                </div>
               </td>
               <td class="blankLineNum gr-diff right"></td>
               <td class="blank gr-diff no-intraline-info right sign"></td>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index d23c9c5..b184be3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -161,9 +161,6 @@
                 >
                   <gr-diff-text>asdf</gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="left">
-                  <slot name="left-1"> </slot>
-                </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
@@ -185,9 +182,6 @@
                 >
                   <gr-diff-text>asdf </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                </div>
               </td>
             </tr>
             <tr
@@ -218,9 +212,6 @@
                 >
                   <gr-diff-text> qwer</gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="left">
-                  <slot name="left-1"> </slot>
-                </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
@@ -242,9 +233,6 @@
                 >
                   <gr-diff-text>qwer </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                </div>
               </td>
             </tr>
             <tr
@@ -275,9 +263,6 @@
                 >
                   <gr-diff-text>zxcv </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="left">
-                  <slot name="left-1"> </slot>
-                </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
@@ -299,9 +284,6 @@
                 >
                   <gr-diff-text>zxcv </gr-diff-text>
                 </div>
-                <div class="thread-group" data-side="right">
-                  <slot name="right-1"> </slot>
-                </div>
               </td>
             </tr>
           </tbody>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 6a32afb..ed6e9da 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -18,20 +18,13 @@
   GrCursorManager,
   isTargetable,
 } from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
-import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
 import {fire} from '../../../utils/event-util';
-
-type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
 
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
-interface Address {
-  leftSide: boolean;
-  number: number;
-}
-
 /**
  * From <tr> diff row go up to <tbody> diff chunk.
  *
@@ -70,17 +63,13 @@
     if (this.sideInternal === side) {
       return;
     }
-    if (this.sideInternal && this.diffRow) {
-      this.fireCursorMoved(
-        'line-cursor-moved-out',
-        this.diffRow,
-        this.sideInternal
-      );
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-out');
     }
     this.sideInternal = side;
     this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-in');
     }
   }
 
@@ -90,28 +79,30 @@
 
   private sideInternal = Side.RIGHT;
 
-  set diffRow(diffRow: HTMLElement | undefined) {
-    if (this.diffRowInternal) {
-      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
-      this.fireCursorMoved(
-        'line-cursor-moved-out',
-        this.diffRowInternal,
-        this.side
+  set diffRowTR(diffRowTR: HTMLTableRowElement | undefined) {
+    if (this.diffRowTRInternal) {
+      this.diffRowTRInternal.classList.remove(
+        LEFT_SIDE_CLASS,
+        RIGHT_SIDE_CLASS
       );
+      this.fireCursorMoved('line-cursor-moved-out');
     }
-    this.diffRowInternal = diffRow;
+    this.diffRowTRInternal = diffRowTR;
 
     this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-in');
     }
   }
 
-  get diffRow(): HTMLElement | undefined {
-    return this.diffRowInternal;
+  /**
+   * This is the current target of the diff cursor.
+   */
+  get diffRowTR(): HTMLTableRowElement | undefined {
+    return this.diffRowTRInternal;
   }
 
-  private diffRowInternal?: HTMLElement;
+  private diffRowTRInternal?: HTMLTableRowElement;
 
   private diffs: GrDiffCursorable[] = [];
 
@@ -135,16 +126,16 @@
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursorManager.focusOnMove = true;
 
-    window.addEventListener('scroll', this._boundHandleWindowScroll);
+    window.addEventListener('scroll', this.boundHandleWindowScroll);
     this.targetSubscription = this.cursorManager.target$.subscribe(target => {
-      this.diffRow = target || undefined;
+      this.diffRowTR = (target ?? undefined) as HTMLTableRowElement | undefined;
     });
   }
 
   dispose() {
     this.cursorManager.unsetCursor();
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
-    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+    window.removeEventListener('scroll', this.boundHandleWindowScroll);
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -159,22 +150,22 @@
 
   moveLeft() {
     this.side = Side.LEFT;
-    if (this._isTargetBlank()) {
+    if (this.isTargetBlank()) {
       this.moveUp();
     }
   }
 
   moveRight() {
     this.side = Side.RIGHT;
-    if (this._isTargetBlank()) {
+    if (this.isTargetBlank()) {
       this.moveUp();
     }
   }
 
   moveDown() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       return this.cursorManager.next({
-        filter: (row: Element) => this._rowHasSide(row),
+        filter: (row: Element) => this.rowHasSide(row),
       });
     } else {
       return this.cursorManager.next();
@@ -182,9 +173,9 @@
   }
 
   moveUp() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       return this.cursorManager.previous({
-        filter: (row: Element) => this._rowHasSide(row),
+        filter: (row: Element) => this.rowHasSide(row),
       });
     } else {
       return this.cursorManager.previous();
@@ -192,9 +183,9 @@
   }
 
   moveToVisibleArea() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       this.cursorManager.moveToVisibleArea((row: Element) =>
-        this._rowHasSide(row)
+        this.rowHasSide(row)
       );
     } else {
       this.cursorManager.moveToVisibleArea();
@@ -203,19 +194,19 @@
 
   moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      filter: (row: HTMLElement) => this.isFirstRowOfChunk(row),
       getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
       clipToTop,
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
   moveToPreviousChunk(): CursorMoveResult {
     const result = this.cursorManager.previous({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      filter: (row: HTMLElement) => this.isFirstRowOfChunk(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
@@ -224,17 +215,17 @@
       return CursorMoveResult.CLIPPED;
     }
     const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._rowHasThread(row),
+      filter: (row: HTMLElement) => this.rowHasThread(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
   moveToPreviousCommentThread(): CursorMoveResult {
     const result = this.cursorManager.previous({
-      filter: (row: HTMLElement) => this._rowHasThread(row),
+      filter: (row: HTMLElement) => this.rowHasThread(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
@@ -244,7 +235,7 @@
     path?: string,
     intentionalMove?: boolean
   ) {
-    const row = this._findRowByNumberAndFile(number, side, path);
+    const row = this.findRowByNumberAndFile(number, side, path);
     if (row) {
       this.side = side;
       this.cursorManager.setCursor(row, undefined, intentionalMove);
@@ -252,47 +243,50 @@
   }
 
   /**
-   * Get the line number element targeted by the cursor row and side.
+   * The target of the diff cursor is always a <tr> element. That is the first
+   * direct child of a <gr-diff-row> element. We typically want to retrieve
+   * the `GrDiffRow`, because it supplies methods that we can use without
+   * making further assumptions about the internal DOM structure.
    */
-  getTargetLineElement(): HTMLElement | null {
-    let lineElSelector = '.lineNum';
-
-    if (!this.diffRow) {
-      return null;
+  getTargetDiffRow(): GrDiffRow | undefined {
+    let el: HTMLElement | undefined = this.diffRowTR;
+    while (el) {
+      if (el.tagName === 'GR-DIFF-ROW') return el as GrDiffRow;
+      el = el.parentElement ?? undefined;
     }
-
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
-    }
-
-    return this.diffRow.querySelector(lineElSelector);
+    return undefined;
   }
 
-  getTargetDiffElement(): GrDiff | null {
-    if (!this.diffRow) return null;
+  getTargetLineNumber(): LineNumber | undefined {
+    const diffRow = this.getTargetDiffRow();
+    return diffRow?.lineNumber(this.side);
+  }
 
-    const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
+  getTargetDiffElement(): GrDiff | undefined {
+    if (!this.diffRowTR) return undefined;
+
+    const hostOwner = this.diffRowTR.getRootNode() as ShadowRoot;
     if (hostOwner?.host?.tagName === 'GR-DIFF') {
       return hostOwner.host as GrDiff;
     }
-    return null;
+    return undefined;
   }
 
   moveToFirstChunk() {
     this.cursorManager.moveToStart();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+    if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
       this.moveToNextChunk(true);
     } else {
-      this._fixSide();
+      this.fixSide();
     }
   }
 
   moveToLastChunk() {
     this.cursorManager.moveToEnd();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+    if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
       this.moveToPreviousChunk();
     } else {
-      this._fixSide();
+      this.fixSide();
     }
   }
 
@@ -306,8 +300,8 @@
    * reset the scroll behavior, use reInitAndUpdateStops() instead.
    */
   reInitCursor() {
-    this._updateStops();
-    if (!this.diffRow) {
+    this.updateStops();
+    if (!this.diffRowTR) {
       // does not scroll during init unless requested
       this.cursorManager.scrollMode = this.initialLineNumber
         ? ScrollMode.KEEP_VISIBLE
@@ -326,7 +320,7 @@
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
-  private _boundHandleWindowScroll = () => {
+  private boundHandleWindowScroll = () => {
     if (this.preventAutoScrollOnManualScroll) {
       this.cursorManager.scrollMode = ScrollMode.NEVER;
       this.cursorManager.focusOnMove = false;
@@ -336,25 +330,25 @@
 
   reInitAndUpdateStops() {
     this.resetScrollMode();
-    this._updateStops();
+    this.updateStops();
   }
 
   private boundHandleDiffLoadingChanged = () => {
-    this._updateStops();
+    this.updateStops();
   };
 
-  private _boundHandleDiffRenderStart = () => {
+  private boundHandleDiffRenderStart = () => {
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderContent = () => {
-    this._updateStops();
+  private boundHandleDiffRenderContent = () => {
+    this.updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
     this.cursorManager.focusOnMove = true;
     this.preventAutoScrollOnManualScroll = false;
   };
 
-  private _boundHandleDiffLineSelected = (
+  private boundHandleDiffLineSelected = (
     e: CustomEvent<LineSelectedEventDetail>
   ) => {
     this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
@@ -367,73 +361,34 @@
     if (diffWithRangeSelected) {
       diffWithRangeSelected.createRangeComment();
     } else {
-      const line = this.getTargetLineElement();
+      const diffRow = this.getTargetDiffRow();
+      const lineNumber = diffRow?.lineNumber(this.side);
       const diff = this.getTargetDiffElement();
-      if (diff && line) {
-        diff.addDraftAtLine(line);
+      if (diff && lineNumber) {
+        diff.addDraftAtLine(lineNumber, this.side);
       }
     }
   }
 
-  /**
-   * Get an object describing the location of the cursor. Such as
-   * {leftSide: false, number: 123} for line 123 of the revision, or
-   * {leftSide: true, number: 321} for line 321 of the base patch.
-   * Returns null if an address is not available.
-   */
-  getAddress(): Address | null {
-    if (!this.diffRow) {
-      return null;
-    }
-    // Get the line-number cell targeted by the cursor. If the mode is unified
-    // then prefer the revision cell if available.
-    return this.getAddressFor(this.diffRow, this.side);
-  }
-
-  private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
-    let cell;
-    if (this._getViewMode() === DiffViewMode.UNIFIED) {
-      cell = diffRow.querySelector('.lineNum.right');
-      if (!cell) {
-        cell = diffRow.querySelector('.lineNum.left');
-      }
-    } else {
-      cell = diffRow.querySelector('.lineNum.' + side);
-    }
-    if (!cell) {
+  private getViewMode() {
+    if (!this.diffRowTR) {
       return null;
     }
 
-    const number = cell.getAttribute('data-value');
-    if (!number || number === 'FILE') {
-      return null;
-    }
-
-    return {
-      leftSide: cell.matches('.left'),
-      number: Number(number),
-    };
-  }
-
-  _getViewMode() {
-    if (!this.diffRow) {
-      return null;
-    }
-
-    if (this.diffRow.classList.contains('side-by-side')) {
+    if (this.diffRowTR.classList.contains('side-by-side')) {
       return DiffViewMode.SIDE_BY_SIDE;
     } else {
       return DiffViewMode.UNIFIED;
     }
   }
 
-  _rowHasSide(row: Element) {
+  private rowHasSide(row: Element) {
     const selector =
       (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
     return !!row.querySelector(selector);
   }
 
-  _isFirstRowOfChunk(row: HTMLElement) {
+  private isFirstRowOfChunk(row: HTMLElement) {
     const chunk = fromRowToChunk(row);
     if (!chunk) return false;
 
@@ -444,7 +399,7 @@
     return firstRow === row;
   }
 
-  _rowHasThread(row: HTMLElement): boolean {
+  private rowHasThread(row: HTMLElement): boolean {
     const slots = [
       ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
     ];
@@ -455,70 +410,38 @@
    * If we jumped to a row where there is no content on the current side then
    * switch to the alternate side.
    */
-  _fixSide() {
+  private fixSide() {
     if (
-      this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
-      this._isTargetBlank()
+      this.getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+      this.isTargetBlank()
     ) {
       this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
     }
   }
 
-  _isTargetBlank() {
-    if (!this.diffRow) {
-      return false;
-    }
-
-    const actions = this._getActionsForRow();
-    return (
-      (this.side === Side.LEFT && !actions.left) ||
-      (this.side === Side.RIGHT && !actions.right)
-    );
+  private isTargetBlank() {
+    const line = this.getTargetDiffRow()?.line(this.side);
+    return line?.type === GrDiffLineType.BLANK;
   }
 
   private fireCursorMoved(
-    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
-    row: HTMLElement,
-    side: Side
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in'
   ) {
-    const address = this.getAddressFor(row, side);
-    if (address) {
-      const {leftSide, number} = address;
-      fire(row, event, {
-        lineNum: number,
-        side: leftSide ? Side.LEFT : Side.RIGHT,
-      });
-    }
+    const lineNum = this.getTargetLineNumber();
+    if (!lineNum) return;
+    fire(this.diffRowTR, event, {lineNum, side: this.side});
   }
 
   private updateSideClass() {
-    if (!this.diffRow) {
+    if (!this.diffRowTR) {
       return;
     }
-    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
-    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
+    toggleClass(this.diffRowTR, LEFT_SIDE_CLASS, this.side === Side.LEFT);
+    toggleClass(this.diffRowTR, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
   }
 
-  _isActionType(type: GrDiffRowType) {
-    return (
-      type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
-    );
-  }
-
-  _getActionsForRow() {
-    const actions = {left: false, right: false};
-    if (this.diffRow) {
-      actions.left = this._isActionType(
-        this.diffRow.getAttribute('left-type') as GrDiffRowType
-      );
-      actions.right = this._isActionType(
-        this.diffRow.getAttribute('right-type') as GrDiffRowType
-      );
-    }
-    return actions;
-  }
-
-  _updateStops() {
+  // visible for testing
+  updateStops() {
     this.cursorManager.stops = this.diffs.reduce(
       (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
@@ -534,7 +457,7 @@
       this.addEventListeners(diff);
     }
     this.diffs.push(...diffs);
-    this._updateStops();
+    this.updateStops();
   }
 
   unregisterDiff(diff: GrDiffCursorable) {
@@ -551,15 +474,12 @@
       'loading-changed',
       this.boundHandleDiffLoadingChanged
     );
-    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener('render-start', this.boundHandleDiffRenderStart);
     diff.removeEventListener(
       'render-content',
-      this._boundHandleDiffRenderContent
+      this.boundHandleDiffRenderContent
     );
-    diff.removeEventListener(
-      'line-selected',
-      this._boundHandleDiffLineSelected
-    );
+    diff.removeEventListener('line-selected', this.boundHandleDiffLineSelected);
   }
 
   private addEventListeners(diff: GrDiffCursorable) {
@@ -567,12 +487,13 @@
       'loading-changed',
       this.boundHandleDiffLoadingChanged
     );
-    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
-    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+    diff.addEventListener('render-start', this.boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this.boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this.boundHandleDiffLineSelected);
   }
 
-  _findRowByNumberAndFile(
+  // visible for testing
+  findRowByNumberAndFile(
     targetNumber: LineNumber,
     side: Side,
     path?: string
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index d8406c0..70fece3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -17,7 +17,7 @@
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {GrDiffCursor} from './gr-diff-cursor';
 import {waitForEventOnce} from '../../../utils/event-util';
-import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {DiffInfo, DiffViewMode, FILE, Side} from '../../../api/diff';
 import {GrDiff} from '../gr-diff/gr-diff';
 import {assertIsDefined} from '../../../utils/common-util';
 
@@ -27,12 +27,13 @@
 
   setup(async () => {
     diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    diffElement.path = 'some/path.ts';
     cursor = new GrDiffCursor();
     cursor.replaceDiffs([diffElement]);
 
     const promise = mockPromise();
     const setupDone = () => {
-      cursor._updateStops();
+      cursor.updateStops();
       cursor.moveToFirstChunk();
       diffElement.removeEventListener('render', setupDone);
       promise.resolve();
@@ -48,23 +49,23 @@
   });
 
   test('diff cursor functionality (side-by-side)', () => {
-    assert.isOk(cursor.diffRow);
+    assert.isOk(cursor.diffRowTR);
 
     const deltaRows = queryAll<HTMLTableRowElement>(
       diffElement,
       '.section.delta tr.diff-row'
     );
-    assert.equal(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
 
     cursor.moveDown();
 
-    assert.notEqual(cursor.diffRow, deltaRows[0]);
-    assert.equal(cursor.diffRow, deltaRows[1]);
+    assert.notEqual(cursor.diffRowTR, deltaRows[0]);
+    assert.equal(cursor.diffRowTR, deltaRows[1]);
 
     cursor.moveUp();
 
-    assert.notEqual(cursor.diffRow, deltaRows[1]);
-    assert.equal(cursor.diffRow, deltaRows[0]);
+    assert.notEqual(cursor.diffRowTR, deltaRows[1]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
   });
 
   test('moveToFirstChunk', async () => {
@@ -104,7 +105,7 @@
     await waitForEventOnce(diffElement, 'render');
     await waitForEventOnce(diffElement, 'render');
 
-    cursor._updateStops();
+    cursor.updateStops();
 
     const chunks = [
       ...queryAll(diffElement, '.section.delta'),
@@ -118,19 +119,19 @@
 
     // Verify it works on fresh diff.
     cursor.moveToFirstChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[0]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
 
     // Verify it works from other cursor positions.
     cursor.moveToNextChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[1]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[1]);
     assert.equal(cursor.side, Side.LEFT);
 
     cursor.moveToFirstChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[0]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -165,7 +166,7 @@
 
     await waitForEventOnce(diffElement, 'render');
     await waitForEventOnce(diffElement, 'render');
-    cursor._updateStops();
+    cursor.updateStops();
 
     const chunks = [
       ...queryAll(diffElement, '.section.delta'),
@@ -179,19 +180,19 @@
 
     // Verify it works on fresh diff.
     cursor.moveToLastChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[1]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
 
     // Verify it works from other cursor positions.
     cursor.moveToPreviousChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[0]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[0]);
     assert.equal(cursor.side, Side.LEFT);
 
     cursor.moveToLastChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[1]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -237,22 +238,22 @@
     });
 
     test('diff cursor functionality (unified)', () => {
-      assert.isOk(cursor.diffRow);
+      assert.isOk(cursor.diffRowTR);
 
       const rows = [
         ...queryAll(diffElement, '.section.delta tr.diff-row'),
       ] as HTMLTableRowElement[];
-      assert.equal(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRowTR, rows[0]);
 
       cursor.moveDown();
 
-      assert.notEqual(cursor.diffRow, rows[0]);
-      assert.equal(cursor.diffRow, rows[1]);
+      assert.notEqual(cursor.diffRowTR, rows[0]);
+      assert.equal(cursor.diffRowTR, rows[1]);
 
       cursor.moveUp();
 
-      assert.notEqual(cursor.diffRow, rows[1]);
-      assert.equal(cursor.diffRow, rows[0]);
+      assert.notEqual(cursor.diffRowTR, rows[1]);
+      assert.equal(cursor.diffRowTR, rows[0]);
     });
   });
 
@@ -275,7 +276,7 @@
     // Because the first delta in this diff is on the right, it should be set
     // to the right side.
     assert.equal(cursor.side, Side.RIGHT);
-    assert.equal(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
     const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
@@ -284,8 +285,8 @@
     cursor.moveLeft();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, rows[0]);
-    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRowTR, rows[0]);
+    assert.equal(cursor.diffRowTR, rowBeforeFirstDelta);
     assert.equal(cursor.cursorManager.index, firstIndex - 1);
 
     // If we move down, we should skip everything in the first delta because
@@ -293,8 +294,8 @@
     cursor.moveDown();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
-    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.notEqual(cursor.diffRowTR, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRowTR, rows[0]);
     assert.isTrue(cursor.cursorManager.index > firstIndex);
   });
 
@@ -303,7 +304,7 @@
 
     // We should be initialized to the first chunk. Since this chunk only has
     // content on the right side, our side should be right.
-    assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
+    assert.equal(cursor.diffRowTR, deltaChunks[0].querySelector('tr'));
     assert.equal(cursor.side, Side.RIGHT);
 
     // Move to the next chunk.
@@ -311,7 +312,7 @@
 
     // Since this chunk only has content on the left side. we should have been
     // automatically moved over.
-    assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
+    assert.equal(cursor.diffRowTR, deltaChunks[1].querySelector('tr'));
     assert.equal(cursor.side, Side.LEFT);
   });
 
@@ -365,7 +366,7 @@
 
   test('getTargetDiffElement', () => {
     cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
+    assert.isTrue(!!cursor.diffRowTR);
     assert.equal(cursor.getTargetDiffElement(), diffElement);
   });
 
@@ -431,51 +432,56 @@
         'createRangeComment'
       );
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
+      cursor.diffRowTR = undefined;
       cursor.createCommentInPlace();
       assert.isFalse(createRangeCommentStub.called);
       assert.isFalse(addDraftAtLineStub.called);
     });
   });
 
-  test('getAddress', () => {
+  test('getTargetLineNumber', () => {
     // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+    assert.deepEqual(cursor.getTargetLineNumber(), 5);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // Revision line 4 is up.
     cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+    assert.deepEqual(cursor.getTargetLineNumber(), 4);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // Base line 4 is left.
     cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+    assert.deepEqual(cursor.getTargetLineNumber(), 4);
+    assert.deepEqual(cursor.side, Side.LEFT);
 
     // Moving to the next chunk takes it back to the start.
     cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+    assert.deepEqual(cursor.getTargetLineNumber(), 5);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // The following chunk is a removal starting on line 10 of the base.
     cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+    assert.deepEqual(cursor.getTargetLineNumber(), 10);
+    assert.deepEqual(cursor.side, Side.LEFT);
 
     // Should be null if there is no selection.
     cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
+    assert.isUndefined(cursor.getTargetLineNumber());
   });
 
-  test('_findRowByNumberAndFile', () => {
+  test('findRowByNumberAndFile', () => {
     // Get the first ab row after the first chunk.
     const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
     const row = rows[9];
     assert.ok(row);
 
     // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+    assert.equal(cursor.findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor.findRowByNumberAndFile(5, Side.LEFT), row);
   });
 
   test('expand context updates stops', async () => {
-    const spy = sinon.spy(cursor, '_updateStops');
+    const spy = sinon.spy(cursor, 'updateStops');
     const controls = queryAndAssert(diffElement, 'gr-context-controls');
     const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
     showContext.click();
@@ -485,7 +491,7 @@
   });
 
   test('updates stops when loading changes', () => {
-    const spy = sinon.spy(cursor, '_updateStops');
+    const spy = sinon.spy(cursor, 'updateStops');
     diffElement.dispatchEvent(new Event('loading-changed'));
     assert.isTrue(spy.called);
   });
@@ -532,26 +538,17 @@
 
       // Goto second last line of the first diff
       cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        `${lastLine - 1}`
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine - 1);
 
       // Can move down until we reach the loading file
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        lastLine.toString()
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine);
 
       // Cannot move down while still loading the diff we would switch to
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        lastLine.toString()
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine);
 
       // Diff 1 finishing to load
       diffElements[1].diff = createDiff();
@@ -561,7 +558,7 @@
       cursor.moveDown(); // LOST
       cursor.moveDown(); // FILE
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
+      assert.equal(cursor.getTargetLineNumber(), FILE);
     });
   });
 });
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 1cdfbc3..5f890ce 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
@@ -15,11 +15,11 @@
   getLineElByChild,
   getLineNumberByChild,
   getSideByLineEl,
-  GrDiffThreadElement,
+  GrDiffCommentThread,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {fire} from '../../../utils/event-util';
+import {DiffModel} from '../gr-diff-model/gr-diff-model';
 
 interface SidedRange {
   side: Side;
@@ -44,6 +44,7 @@
  */
 export interface DiffBuilderInterface {
   getContentTdByLineEl(lineEl?: Element): Element | undefined;
+  diffModel: DiffModel;
 }
 
 /**
@@ -134,64 +135,31 @@
     );
   }
 
-  private getThreadEl(e: Event): GrDiffThreadElement | null {
-    for (const pathEl of e.composedPath()) {
-      if (
-        pathEl instanceof HTMLElement &&
-        pathEl.classList.contains('comment-thread')
-      ) {
-        return pathEl as GrDiffThreadElement;
-      }
-    }
-    return null;
-  }
-
   private toggleRangeElHighlight(
-    threadEl: GrDiffThreadElement | null,
+    thread: GrDiffCommentThread,
     highlightRange = false
   ) {
-    const rootId = threadEl?.rootId;
+    const rootId = thread?.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')
-        );
+    const highlightClass = highlightRange ? 'range' : 'rangeHoverHighlight';
+    const selector = `.${highlightClass}.${strToClassName(rootId)}`;
+    const rangeNodes = this.diffTable.querySelectorAll(selector);
+    for (const rangeNode of rangeNodes) {
+      rangeNode.classList.toggle('rangeHoverHighlight', highlightRange);
     }
   }
 
-  private handleCommentThreadMouseenter = (e: Event) => {
-    const threadEl = this.getThreadEl(e);
-    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  private handleCommentThreadMouseenter = (
+    e: CustomEvent<GrDiffCommentThread>
+  ) => {
+    this.toggleRangeElHighlight(e.detail, /* highlightRange= */ true);
   };
 
-  private handleCommentThreadMouseleave = (e: Event) => {
-    const threadEl = this.getThreadEl(e);
-    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  private handleCommentThreadMouseleave = (
+    e: CustomEvent<GrDiffCommentThread>
+  ) => {
+    this.toggleRangeElHighlight(e.detail, /* highlightRange= */ false);
   };
 
   /**
@@ -407,7 +375,7 @@
       // 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, {
+        this.createRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -457,10 +425,9 @@
     }
   }
 
-  private fireCreateRangeComment(side: Side, range: CommentRange) {
-    if (this.diffTable) {
-      fire(this.diffTable, 'create-range-comment', {side, range});
-    }
+  private createRangeComment(side: Side, range: CommentRange) {
+    assertIsDefined(this.diffBuilder, 'diffBuilder');
+    this.diffBuilder?.diffModel.createCommentOnRange(range, side);
     this.removeActionBox();
   }
 
@@ -468,7 +435,7 @@
     e.stopPropagation();
     assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this.fireCreateRangeComment(side, range);
+    this.createRangeComment(side, range);
   };
 
   // visible for testing
@@ -512,14 +479,3 @@
     }
   }
 }
-
-export interface CreateRangeCommentEventDetail {
-  side: Side;
-  range: CommentRange;
-}
-
-declare global {
-  interface HTMLElementEventMap {
-    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
-  }
-}
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
index f04e6a2..32decb1 100644
--- 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
@@ -7,21 +7,21 @@
 import './gr-diff-highlight';
 import {getTextOffset} from './gr-range-normalizer';
 import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
-import {
-  GrDiffHighlight,
-  DiffBuilderInterface,
-  CreateRangeCommentEventDetail,
-} from './gr-diff-highlight';
+import {GrDiffHighlight, DiffBuilderInterface} 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 {
+  GrDiffThreadElement,
+  getDataFromCommentThreadEl,
+} from '../gr-diff/gr-diff-utils';
 import {
   stubElement,
   waitQueryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+import {DiffModel} from '../gr-diff-model/gr-diff-model';
 
 // Splitting long lines in html into shorter rows breaks tests:
 // zero-length text nodes and new lines are not expected in some places
@@ -132,16 +132,13 @@
     let hlRange: HTMLElement;
     let element: GrDiffHighlight;
     let diff: HTMLElement;
-    let builder: {
-      getContentTdByLineEl: SinonStubbedMember<
-        DiffBuilderInterface['getContentTdByLineEl']
-      >;
-    };
+    let builder: DiffBuilderInterface;
 
     setup(async () => {
       diff = await fixture<HTMLTableElement>(diffTable);
       builder = {
         getContentTdByLineEl: sinon.stub(),
+        diffModel: new DiffModel(document),
       };
       element = new GrDiffHighlight();
       element.init(diff, builder);
@@ -152,6 +149,8 @@
       ) as unknown as GrDiffThreadElement;
       threadEl.className = 'comment-thread';
       threadEl.rootId = 'id314';
+      threadEl.setAttribute('line-num', '12');
+      threadEl.setAttribute('diff-side', 'right');
       diff.appendChild(threadEl);
     });
 
@@ -164,6 +163,7 @@
       assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
       threadEl.dispatchEvent(
         new CustomEvent('comment-thread-mouseenter', {
+          detail: getDataFromCommentThreadEl(threadEl),
           bubbles: true,
           composed: true,
         })
@@ -176,6 +176,7 @@
       hlRange.classList.add('rangeHoverHighlight');
       threadEl.dispatchEvent(
         new CustomEvent('comment-thread-mouseleave', {
+          detail: getDataFromCommentThreadEl(threadEl),
           bubbles: true,
           composed: true,
         })
@@ -187,23 +188,23 @@
     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 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);
+      element.selectedRange = {side: Side.LEFT, range};
+      const createCommentStub = sinon.stub(
+        builder.diffModel,
+        'createCommentOnRange'
+      );
+
+      diff.dispatchEvent(new CustomEvent('create-comment-requested'));
+
+      assert.isTrue(createCommentStub.called);
+      assert.deepEqual(createCommentStub.lastCall.args[0], range);
+      assert.equal(createCommentStub.lastCall.args[1], Side.LEFT);
       assert.isTrue(removeActionBoxStub.called);
     });
   });
@@ -211,18 +212,18 @@
   suite('selection', () => {
     let element: GrDiffHighlight;
     let diff: HTMLElement;
-    let builder: {
-      getContentTdByLineEl: SinonStubbedMember<
-        DiffBuilderInterface['getContentTdByLineEl']
-      >;
-    };
+    let getContentTdByLineElStub: SinonStubbedMember<
+      DiffBuilderInterface['getContentTdByLineEl']
+    >;
     let contentStubs;
 
     setup(async () => {
       diff = await fixture<HTMLTableElement>(diffTable);
-      builder = {
-        getContentTdByLineEl: sinon.stub(),
+      const builder: DiffBuilderInterface = {
+        getContentTdByLineEl: () => undefined,
+        diffModel: new DiffModel(document),
       };
+      getContentTdByLineElStub = sinon.stub(builder, 'getContentTdByLineEl');
       element = new GrDiffHighlight();
       element.init(diff, builder);
       contentStubs = [];
@@ -251,7 +252,7 @@
         contentTd,
         contentText,
       });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      getContentTdByLineElStub.withArgs(lineEl).returns(contentTd);
       return contentText;
     };
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index b8539ef..8becace 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -6,13 +6,17 @@
 import {Observable, combineLatest, from} from 'rxjs';
 import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
 import {
+  CreateCommentEventDetail,
   DiffInfo,
   DiffLayer,
   DiffPreferencesInfo,
   DiffResponsiveMode,
   DiffViewMode,
   DisplayLine,
+  LineNumber,
+  LineSelectedEventDetail,
   RenderPreferences,
+  Side,
 } from '../../../api/diff';
 import {define} from '../../../models/dependency';
 import {Model} from '../../../models/model';
@@ -35,6 +39,8 @@
 import {assert} from '../../../utils/common-util';
 import {isImageDiff} from '../../../utils/diff-util';
 import {ImageInfo} from '../../../types/common';
+import {fire} from '../../../utils/event-util';
+import {CommentRange} from '../../../api/rest-api';
 
 export interface DiffState {
   diff?: DiffInfo;
@@ -119,6 +125,11 @@
     diffState => diffState.errorMessage
   );
 
+  readonly comments$: Observable<GrDiffCommentThread[]> = select(
+    this.state$,
+    diffState => diffState.comments ?? []
+  );
+
   readonly groups$: Observable<GrDiffGroup[]> = select(
     this.state$,
     diffState => diffState.groups ?? []
@@ -140,7 +151,14 @@
       computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
   );
 
-  constructor() {
+  constructor(
+    /**
+     * Normally a reference to the <gr-diff> component. Used for firing events
+     * that are meant for <gr-diff> or the host of <gr-diff>. For tests this
+     * can also be just `document`.
+     */
+    private readonly eventTarget: EventTarget
+  ) {
     super({
       diffPrefs: createDefaultDiffPrefs(),
       renderPrefs: {},
@@ -191,4 +209,30 @@
     groups.splice(i, 1, ...newGroups);
     this.updateState({groups});
   }
+
+  selectLine(number: LineNumber, side: Side) {
+    const path = this.getState().path;
+    if (!path) return;
+
+    const detail: LineSelectedEventDetail = {number, side, path};
+    fire(this.eventTarget, 'line-selected', detail);
+  }
+
+  createCommentOnLine(lineNum: LineNumber, side: Side) {
+    const detail: CreateCommentEventDetail = {
+      side,
+      lineNum,
+      range: undefined,
+    };
+    fire(this.eventTarget, 'create-comment', detail);
+  }
+
+  createCommentOnRange(range: CommentRange, side: Side) {
+    const detail: CreateCommentEventDetail = {
+      side,
+      lineNum: range.end_line,
+      range,
+    };
+    fire(this.eventTarget, 'create-comment', detail);
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
index 6f476e5..56336f0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
@@ -39,7 +39,7 @@
   };
 
   setup(async () => {
-    model = new DiffModel();
+    model = new DiffModel(document);
     element = (
       await fixture(
         wrapInProvider(
@@ -102,9 +102,9 @@
                   <td class="blame gr-diff" data-line-number="LOST"></td>
                   <td class="gr-diff left lineNum" data-value="LOST"></td>
                   <td class="gr-diff lineNum right" data-value="LOST"></td>
-                  <td class="both content gr-diff lost no-intraline-info right">
-                    <div class="thread-group" data-side="right"></div>
-                  </td>
+                  <td
+                    class="both content gr-diff lost no-intraline-info right"
+                  ></td>
                 </tr>
               </tbody>
               <tbody class="both gr-diff section">
@@ -122,7 +122,7 @@
                       id="left-button-FILE"
                       tabindex="-1"
                     >
-                      File
+                      FILE
                     </button>
                   </td>
                   <td class="gr-diff lineNum right" data-value="FILE">
@@ -133,12 +133,12 @@
                       id="right-button-FILE"
                       tabindex="-1"
                     >
-                      File
+                      FILE
                     </button>
                   </td>
-                  <td class="both content file gr-diff no-intraline-info right">
-                    <div class="thread-group" data-side="right"></div>
-                  </td>
+                  <td
+                    class="both content file gr-diff no-intraline-info right"
+                  ></td>
                 </tr>
               </tbody>
               <tbody class="both gr-diff section">
@@ -176,7 +176,6 @@
                       data-side="right"
                       id="right-content-1"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -213,7 +212,6 @@
                       data-side="right"
                       id="right-content-2"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -250,7 +248,6 @@
                       data-side="right"
                       id="right-content-3"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -287,7 +284,6 @@
                       data-side="right"
                       id="right-content-4"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -316,7 +312,6 @@
                       data-side="right"
                       id="right-content-5"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -343,7 +338,6 @@
                       data-side="right"
                       id="right-content-6"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -370,7 +364,6 @@
                       data-side="right"
                       id="right-content-7"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -409,7 +402,6 @@
                       data-side="right"
                       id="right-content-8"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -446,7 +438,6 @@
                       data-side="right"
                       id="right-content-9"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -483,7 +474,6 @@
                       data-side="right"
                       id="right-content-10"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -520,7 +510,6 @@
                       data-side="right"
                       id="right-content-11"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -557,7 +546,6 @@
                       data-side="right"
                       id="right-content-12"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -586,7 +574,6 @@
                       data-side="left"
                       id="left-content-10"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                 </tr>
                 <tr
@@ -613,7 +600,6 @@
                       data-side="left"
                       id="left-content-11"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                 </tr>
                 <tr
@@ -640,7 +626,6 @@
                       data-side="left"
                       id="left-content-12"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                 </tr>
                 <tr
@@ -667,7 +652,6 @@
                       data-side="left"
                       id="left-content-13"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                 </tr>
               </tbody>
@@ -696,7 +680,6 @@
                       data-side="right"
                       id="right-content-13"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -723,7 +706,6 @@
                       data-side="right"
                       id="right-content-14"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -752,7 +734,6 @@
                       data-side="left"
                       id="left-content-16"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                 </tr>
                 <tr
@@ -779,7 +760,6 @@
                       data-side="right"
                       id="right-content-15"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -818,7 +798,6 @@
                       data-side="right"
                       id="right-content-16"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -855,7 +834,6 @@
                       data-side="right"
                       id="right-content-17"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -892,7 +870,6 @@
                       data-side="right"
                       id="right-content-18"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -952,7 +929,6 @@
                       data-side="right"
                       id="right-content-37"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -989,7 +965,6 @@
                       data-side="right"
                       id="right-content-38"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1026,7 +1001,6 @@
                       data-side="right"
                       id="right-content-39"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1055,7 +1029,6 @@
                       data-side="right"
                       id="right-content-40"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1082,7 +1055,6 @@
                       data-side="right"
                       id="right-content-41"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1109,7 +1081,6 @@
                       data-side="right"
                       id="right-content-42"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1136,7 +1107,6 @@
                       data-side="right"
                       id="right-content-43"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1175,7 +1145,6 @@
                       data-side="right"
                       id="right-content-44"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1212,7 +1181,6 @@
                       data-side="right"
                       id="right-content-45"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1249,7 +1217,6 @@
                       data-side="right"
                       id="right-content-46"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1286,7 +1253,6 @@
                       data-side="right"
                       id="right-content-47"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1323,7 +1289,6 @@
                       data-side="right"
                       id="right-content-48"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1376,14 +1341,14 @@
                   <td class="blame gr-diff" data-line-number="LOST"></td>
                   <td class="gr-diff left lineNum" data-value="LOST"></td>
                   <td class="gr-diff left no-intraline-info sign"></td>
-                  <td class="both content gr-diff left lost no-intraline-info">
-                    <div class="thread-group" data-side="left"></div>
-                  </td>
+                  <td
+                    class="both content gr-diff left lost no-intraline-info"
+                  ></td>
                   <td class="gr-diff lineNum right" data-value="LOST"></td>
                   <td class="gr-diff no-intraline-info right sign"></td>
-                  <td class="both content gr-diff lost no-intraline-info right">
-                    <div class="thread-group" data-side="right"></div>
-                  </td>
+                  <td
+                    class="both content gr-diff lost no-intraline-info right"
+                  ></td>
                 </tr>
               </tbody>
               <tbody class="both gr-diff section">
@@ -1403,13 +1368,13 @@
                       id="left-button-FILE"
                       tabindex="-1"
                     >
-                      File
+                      FILE
                     </button>
                   </td>
                   <td class="gr-diff left no-intraline-info sign"></td>
-                  <td class="both content file gr-diff left no-intraline-info">
-                    <div class="thread-group" data-side="left"></div>
-                  </td>
+                  <td
+                    class="both content file gr-diff left no-intraline-info"
+                  ></td>
                   <td class="gr-diff lineNum right" data-value="FILE">
                     <button
                       aria-label="Add file comment"
@@ -1418,13 +1383,13 @@
                       id="right-button-FILE"
                       tabindex="-1"
                     >
-                      File
+                      FILE
                     </button>
                   </td>
                   <td class="gr-diff no-intraline-info right sign"></td>
-                  <td class="both content file gr-diff no-intraline-info right">
-                    <div class="thread-group" data-side="right"></div>
-                  </td>
+                  <td
+                    class="both content file gr-diff no-intraline-info right"
+                  ></td>
                 </tr>
               </tbody>
               <tbody class="both gr-diff section">
@@ -1454,7 +1419,6 @@
                       data-side="left"
                       id="left-content-1"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="1">
                     <button
@@ -1474,7 +1438,6 @@
                       data-side="right"
                       id="right-content-1"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1503,7 +1466,6 @@
                       data-side="left"
                       id="left-content-2"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="2">
                     <button
@@ -1523,7 +1485,6 @@
                       data-side="right"
                       id="right-content-2"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1552,7 +1513,6 @@
                       data-side="left"
                       id="left-content-3"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="3">
                     <button
@@ -1572,7 +1532,6 @@
                       data-side="right"
                       id="right-content-3"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1601,7 +1560,6 @@
                       data-side="left"
                       id="left-content-4"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="4">
                     <button
@@ -1621,7 +1579,6 @@
                       data-side="right"
                       id="right-content-4"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1657,7 +1614,6 @@
                       data-side="right"
                       id="right-content-5"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1691,7 +1647,6 @@
                       data-side="right"
                       id="right-content-6"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1725,7 +1680,6 @@
                       data-side="right"
                       id="right-content-7"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1756,7 +1710,6 @@
                       data-side="left"
                       id="left-content-5"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="8">
                     <button
@@ -1776,7 +1729,6 @@
                       data-side="right"
                       id="right-content-8"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1805,7 +1757,6 @@
                       data-side="left"
                       id="left-content-6"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="9">
                     <button
@@ -1825,7 +1776,6 @@
                       data-side="right"
                       id="right-content-9"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1854,7 +1804,6 @@
                       data-side="left"
                       id="left-content-7"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="10">
                     <button
@@ -1874,7 +1823,6 @@
                       data-side="right"
                       id="right-content-10"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1903,7 +1851,6 @@
                       data-side="left"
                       id="left-content-8"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="11">
                     <button
@@ -1923,7 +1870,6 @@
                       data-side="right"
                       id="right-content-11"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -1952,7 +1898,6 @@
                       data-side="left"
                       id="left-content-9"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="12">
                     <button
@@ -1972,7 +1917,6 @@
                       data-side="right"
                       id="right-content-12"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2003,7 +1947,6 @@
                       data-side="left"
                       id="left-content-10"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
@@ -2037,7 +1980,6 @@
                       data-side="left"
                       id="left-content-11"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
@@ -2071,7 +2013,6 @@
                       data-side="left"
                       id="left-content-12"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
@@ -2105,7 +2046,6 @@
                       data-side="left"
                       id="left-content-13"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
@@ -2141,7 +2081,6 @@
                       data-side="left"
                       id="left-content-14"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="13">
                     <button
@@ -2161,7 +2100,6 @@
                       data-side="right"
                       id="right-content-13"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2190,7 +2128,6 @@
                       data-side="left"
                       id="left-content-15"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="14">
                     <button
@@ -2210,7 +2147,6 @@
                       data-side="right"
                       id="right-content-14"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2241,7 +2177,6 @@
                       data-side="left"
                       id="left-content-16"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="15">
                     <button
@@ -2261,7 +2196,6 @@
                       data-side="right"
                       id="right-content-15"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2292,7 +2226,6 @@
                       data-side="left"
                       id="left-content-17"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="16">
                     <button
@@ -2312,7 +2245,6 @@
                       data-side="right"
                       id="right-content-16"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2341,7 +2273,6 @@
                       data-side="left"
                       id="left-content-18"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="17">
                     <button
@@ -2361,7 +2292,6 @@
                       data-side="right"
                       id="right-content-17"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2390,7 +2320,6 @@
                       data-side="left"
                       id="left-content-19"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="18">
                     <button
@@ -2410,7 +2339,6 @@
                       data-side="right"
                       id="right-content-18"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2479,7 +2407,6 @@
                       data-side="left"
                       id="left-content-38"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="37">
                     <button
@@ -2499,7 +2426,6 @@
                       data-side="right"
                       id="right-content-37"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2528,7 +2454,6 @@
                       data-side="left"
                       id="left-content-39"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="38">
                     <button
@@ -2548,7 +2473,6 @@
                       data-side="right"
                       id="right-content-38"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2577,7 +2501,6 @@
                       data-side="left"
                       id="left-content-40"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="39">
                     <button
@@ -2597,7 +2520,6 @@
                       data-side="right"
                       id="right-content-39"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2633,7 +2555,6 @@
                       data-side="right"
                       id="right-content-40"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2667,7 +2588,6 @@
                       data-side="right"
                       id="right-content-41"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2701,7 +2621,6 @@
                       data-side="right"
                       id="right-content-42"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2735,7 +2654,6 @@
                       data-side="right"
                       id="right-content-43"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -2766,7 +2684,6 @@
                       data-side="left"
                       id="left-content-41"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="44">
                     <button
@@ -2786,7 +2703,6 @@
                       data-side="right"
                       id="right-content-44"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2815,7 +2731,6 @@
                       data-side="left"
                       id="left-content-42"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="45">
                     <button
@@ -2835,7 +2750,6 @@
                       data-side="right"
                       id="right-content-45"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2864,7 +2778,6 @@
                       data-side="left"
                       id="left-content-43"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="46">
                     <button
@@ -2884,7 +2797,6 @@
                       data-side="right"
                       id="right-content-46"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2913,7 +2825,6 @@
                       data-side="left"
                       id="left-content-44"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="47">
                     <button
@@ -2933,7 +2844,6 @@
                       data-side="right"
                       id="right-content-47"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -2962,7 +2872,6 @@
                       data-side="left"
                       id="left-content-45"
                     ></div>
-                    <div class="thread-group" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="48">
                     <button
@@ -2982,7 +2891,6 @@
                       data-side="right"
                       id="right-content-48"
                     ></div>
-                    <div class="thread-group" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -3057,17 +2965,13 @@
                         id="left-button-FILE"
                         tabindex="-1"
                       >
-                        File
+                        FILE
                       </button>
                     </td>
                     <td class="gr-diff left no-intraline-info sign"></td>
                     <td
                       class="both content file gr-diff left no-intraline-info"
-                    >
-                      <div class="thread-group" data-side="left">
-                        <slot name="left-FILE"> </slot>
-                      </div>
-                    </td>
+                    ></td>
                     <td class="gr-diff lineNum right" data-value="FILE">
                       <button
                         aria-label="Add file comment"
@@ -3076,17 +2980,13 @@
                         id="right-button-FILE"
                         tabindex="-1"
                       >
-                        File
+                        FILE
                       </button>
                     </td>
                     <td class="gr-diff no-intraline-info right sign"></td>
                     <td
                       class="both content file gr-diff no-intraline-info right"
-                    >
-                      <div class="thread-group" data-side="right">
-                        <slot name="right-FILE"> </slot>
-                      </div>
-                    </td>
+                    ></td>
                   </tr>
                 </tbody>
                 <tbody class="binary-diff gr-diff">
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index cfb64b9..c41dc91 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -163,8 +163,9 @@
 }
 
 /**
- * This is all the data that gr-diff extracts from comment thread elements.
- * Otherwise gr-diff treats such elements as a black box.
+ * This is all the data that gr-diff extracts from comment thread elements,
+ * see `GrDiffThreadElement`. Otherwise gr-diff treats such elements as a black
+ * box.
  */
 export interface GrDiffCommentThread {
   side: Side;
@@ -173,8 +174,12 @@
   rootId?: string;
 }
 
-export function toCommentThreadModel(
-  threadEl: HTMLElement
+/**
+ * Retrieves all the data from a comment thread element that the gr-diff API
+ * contract defines for such elements.
+ */
+export function getDataFromCommentThreadEl(
+  threadEl?: EventTarget | null
 ): GrDiffCommentThread | undefined {
   if (!isThreadEl(threadEl)) return undefined;
   const side = getSide(threadEl);
@@ -300,14 +305,19 @@
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
 // TODO: Also document the required HTML attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num'.
+// have, e.g. 'diff-side', 'range' (optional), 'line-num'.
+// Comment widgets are also required to have `comment-thread` in their css
+// class list.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
 }
 
-export function isThreadEl(node: Node): node is GrDiffThreadElement {
+export function isThreadEl(
+  node?: Node | EventTarget | null
+): node is GrDiffThreadElement {
   return (
-    node.nodeType === Node.ELEMENT_NODE &&
+    !!node &&
+    (node as Node).nodeType === Node.ELEMENT_NODE &&
     (node as Element).classList.contains('comment-thread')
   );
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 639b1ac..44f4f60 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -12,7 +12,7 @@
   getRange,
   computeKeyLocations,
   GrDiffCommentThread,
-  toCommentThreadModel,
+  getDataFromCommentThreadEl,
   compareComments,
   GrDiffThreadElement,
   computeContext,
@@ -290,7 +290,7 @@
       el.setAttribute('line-num', '3');
       el.rootId = 'ab12';
 
-      assert.deepEqual(toCommentThreadModel(el), {
+      assert.deepEqual(getDataFromCommentThreadEl(el), {
         line: 3,
         side: Side.LEFT,
         range: undefined,
@@ -306,7 +306,7 @@
       el.setAttribute('diff-side', 'left');
       el.rootId = 'ab12';
 
-      assert.deepEqual(toCommentThreadModel(el), {
+      assert.deepEqual(getDataFromCommentThreadEl(el), {
         line: FILE,
         side: Side.LEFT,
         range: undefined,
@@ -318,11 +318,11 @@
       const el = document.createElement(
         'div'
       ) as unknown as GrDiffThreadElement;
-      assert.isUndefined(toCommentThreadModel(el));
+      assert.isUndefined(getDataFromCommentThreadEl(el));
       el.className = 'comment-thread';
-      assert.isUndefined(toCommentThreadModel(el));
+      assert.isUndefined(getDataFromCommentThreadEl(el));
       el.setAttribute('line-num', '3');
-      assert.isUndefined(toCommentThreadModel(el));
+      assert.isUndefined(getDataFromCommentThreadEl(el));
     });
   });
 
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 c118dd9..ec6ed03 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -16,35 +16,23 @@
 import './gr-diff-element';
 import '../gr-diff-builder/gr-diff-row';
 import {
-  getLine,
-  getLineElByChild,
   getLineNumber,
-  getRange,
-  getSide,
-  GrDiffThreadElement,
-  isLongCommentRange,
   isThreadEl,
-  rangesEqual,
   getResponsiveMode,
   isResponsive,
   isNewDiff,
   getSideByLineEl,
   compareComments,
-  toCommentThreadModel,
+  getDataFromCommentThreadEl,
   FullContext,
   DiffContextExpandedEventDetail,
+  GrDiffCommentThread,
 } from '../gr-diff/gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {
-  CreateRangeCommentEventDetail,
-  GrDiffHighlight,
-} from '../gr-diff-highlight/gr-diff-highlight';
+import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {CoverageRange, DiffLayer, isDefined} from '../../../types/types';
-import {
-  CommentRangeLayer,
-  GrRangedCommentLayer,
-} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {GrRangedCommentLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
@@ -55,10 +43,9 @@
   GrDiff as GrDiffApi,
   DisplayLine,
   LineNumber,
-  LOST,
   ContentLoadNeededEventDetail,
 } from '../../../api/diff';
-import {isHtmlElement, isSafari, toggleClass} from '../../../utils/dom-util';
+import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 import {property, query, state} from 'lit/decorators.js';
@@ -165,10 +152,6 @@
   @property({type: Boolean})
   noRenderOnPrefsChange?: boolean;
 
-  // Private but used in tests.
-  @state()
-  commentRanges: CommentRangeLayer[] = [];
-
   // explicitly highlight a range if it is not associated with any comment
   @property({type: Object})
   highlightRange?: CommentRange;
@@ -252,7 +235,7 @@
   highlights = new GrDiffHighlight();
 
   // Private but used in tests.
-  diffModel = new DiffModel();
+  diffModel = new DiffModel(this);
 
   /**
    * Just the layers that are passed in from the outside. Will be joined with
@@ -306,13 +289,8 @@
       () => this.diffModel.groups$,
       groups => (this.groups = groups)
     );
-    this.addEventListener(
-      'create-range-comment',
-      (e: CustomEvent<CreateRangeCommentEventDetail>) =>
-        this.handleCreateRangeComment(e)
-    );
     this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
-      this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+      this.diffModel.selectLine(e.detail.lineNum, e.detail.side);
     });
     this.addEventListener(
       'diff-context-expanded-internal-new',
@@ -430,7 +408,7 @@
 
   override render() {
     fire(this, 'render-start', {});
-    return html`<gr-diff-element @click=${this.handleTap}></gr-diff-element>`;
+    return html`<gr-diff-element></gr-diff-element>`;
   }
 
   private addSelectionListeners() {
@@ -472,63 +450,22 @@
       : document.getSelection();
   }
 
-  private updateRanges(
-    addedThreadEls: GrDiffThreadElement[],
-    removedThreadEls: GrDiffThreadElement[]
-  ) {
-    function commentRangeFromThreadEl(
-      threadEl: GrDiffThreadElement
-    ): CommentRangeLayer | undefined {
-      const side = getSide(threadEl);
-      if (!side) return undefined;
-      const range = getRange(threadEl);
-      if (!range) return undefined;
+  private commentThreadRedispatcher = (
+    target: EventTarget | null,
+    eventName: 'comment-thread-mouseenter' | 'comment-thread-mouseleave'
+  ) => {
+    if (!isThreadEl(target)) return;
+    const data = getDataFromCommentThreadEl(target);
+    if (data) fire(target, eventName, data);
+  };
 
-      return {side, range, rootId: threadEl.rootId};
-    }
+  private commentThreadEnterRedispatcher = (e: Event) => {
+    this.commentThreadRedispatcher(e.target, 'comment-thread-mouseenter');
+  };
 
-    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
-    const addedCommentRanges = addedThreadEls
-      .map(commentRangeFromThreadEl)
-      .filter(range => !!range) as CommentRangeLayer[];
-    const removedCommentRanges = removedThreadEls
-      .map(commentRangeFromThreadEl)
-      .filter(range => !!range) as CommentRangeLayer[];
-    for (const removedCommentRange of removedCommentRanges) {
-      const i = this.commentRanges.findIndex(
-        cr =>
-          cr.side === removedCommentRange.side &&
-          rangesEqual(cr.range, removedCommentRange.range)
-      );
-      this.commentRanges.splice(i, 1);
-    }
-
-    if (addedCommentRanges?.length) {
-      this.commentRanges.push(...addedCommentRanges);
-    }
-    if (this.highlightRange) {
-      this.commentRanges.push({
-        side: Side.RIGHT,
-        range: this.highlightRange,
-        rootId: '',
-      });
-    }
-
-    this.rangeLayer?.updateRanges(this.commentRanges);
-  }
-
-  // Dispatch events that are handled by the gr-diff-highlight.
-  private redispatchHoverEvents(
-    hoverEl: HTMLElement,
-    threadEl: GrDiffThreadElement
-  ) {
-    hoverEl.addEventListener('mouseenter', () => {
-      fire(threadEl, 'comment-thread-mouseenter', {});
-    });
-    hoverEl.addEventListener('mouseleave', () => {
-      fire(threadEl, 'comment-thread-mouseleave', {});
-    });
-  }
+  private commentThreadLeaveRedispatcher = (e: Event) => {
+    this.commentThreadRedispatcher(e.target, 'comment-thread-mouseleave');
+  };
 
   /** TODO: Can be removed when diff-old is gone. */
   cancel() {}
@@ -565,105 +502,21 @@
   }
 
   // Private but used in tests.
-  handleTap(e: Event) {
-    const el = e.target as Element;
-
-    if (
-      el.getAttribute('data-value') !== LOST &&
-      (el.classList.contains('lineNum') ||
-        el.classList.contains('lineNumButton'))
-    ) {
-      this.addDraftAtLine(el);
-    } else if (
-      el.tagName === 'HL' ||
-      el.classList.contains('content') ||
-      el.classList.contains('contentText')
-    ) {
-      const target = getLineElByChild(el);
-      if (target) {
-        this.selectLine(target);
-      }
-    }
-  }
-
-  // Private but used in tests.
   selectLine(el: Element) {
     const lineNumber = Number(el.getAttribute('data-value'));
     const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
-    this.dispatchSelectedLine(lineNumber, side);
+    this.diffModel.selectLine(lineNumber, side);
   }
 
-  private dispatchSelectedLine(number: LineNumber, side: Side) {
-    fire(this, 'line-selected', {
-      number,
-      side,
-      path: this.path,
-    });
-  }
-
-  addDraftAtLine(el: Element) {
-    this.selectLine(el);
-
-    const lineNum = getLineNumber(el);
-    if (lineNum === null) {
-      fireAlert(this, 'Invalid line number');
-      return;
-    }
-
-    this.createComment(el, lineNum);
+  addDraftAtLine(lineNum: LineNumber, side: Side) {
+    this.diffModel.createCommentOnLine(lineNum, side);
   }
 
   createRangeComment() {
-    if (!this.isRangeSelected()) {
-      throw Error('Selection is needed for new range comment');
-    }
     const selectedRange = this.highlights.selectedRange;
-    if (!selectedRange) throw Error('selected range not set');
+    assertIsDefined(selectedRange, 'no range selected');
     const {side, range} = selectedRange;
-    this.createCommentForSelection(side, range);
-  }
-
-  createCommentForSelection(side: Side, range: CommentRange) {
-    const lineNum = range.end_line;
-    const lineEl = this.getLineElByNumber(lineNum, side);
-    if (lineEl) {
-      this.createComment(lineEl, lineNum, side, range);
-    }
-  }
-
-  private handleCreateRangeComment(
-    e: CustomEvent<CreateRangeCommentEventDetail>
-  ) {
-    const range = e.detail.range;
-    const side = e.detail.side;
-    this.createCommentForSelection(side, range);
-  }
-
-  // Private but used in tests.
-  createComment(
-    lineEl: Element,
-    lineNum: LineNumber,
-    side?: Side,
-    range?: CommentRange
-  ) {
-    const contentEl = this.getContentTdByLineEl(lineEl);
-    if (!contentEl) throw new Error('content el not found for line el');
-    side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
-    fire(this, 'create-comment', {
-      side,
-      lineNum,
-      range,
-    });
-  }
-
-  private getCommentSideByLineAndContent(
-    lineEl: Element,
-    contentEl: Element
-  ): Side {
-    return lineEl.classList.contains(Side.LEFT) ||
-      contentEl.classList.contains('remove')
-      ? Side.LEFT
-      : Side.RIGHT;
+    this.diffModel.createCommentOnRange(range, side);
   }
 
   private lineOfInterestChanged() {
@@ -781,101 +634,33 @@
    * This must be called once, but only after diff lines are rendered. Otherwise
    * `processNodes()` will fail to lookup the HTML elements that it wants to
    * manipulate.
+   *
+   * TODO: Validate whether the above comment is still true. We don't look up
+   * elements anymore, and processing the nodes earlier might be beneficial
+   * performance wise.
    */
   private observeNodes() {
     if (this.nodeObserver) return;
-    // First stop observing old nodes.
-    // Then introduce a Mutation observer that watches for children being added
-    // to gr-diff. If those children are `isThreadEl`, namely then they are
-    // processed.
-    this.nodeObserver = new MutationObserver(mutations => {
-      const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
-      const removedThreadEls =
-        extractRemovedNodes(mutations).filter(isThreadEl);
-      this.processNodes(addedThreadEls, removedThreadEls);
-    });
+    // Watches children being added to gr-diff. We are expecting only comment
+    // widgets to be direct children.
+    this.nodeObserver = new MutationObserver(() => this.processNodes());
     this.nodeObserver.observe(this, {childList: true});
-    // Make sure to process existing gr-comment-threads that already exist.
-    this.processNodes([...this.childNodes].filter(isThreadEl), []);
+    // Process existing comment widgets before the first observed change.
+    this.processNodes();
   }
 
-  private processNodes(
-    addedThreadEls: GrDiffThreadElement[],
-    removedThreadEls: GrDiffThreadElement[]
-  ) {
-    this.diffModel.updateState({
-      comments: [...this.childNodes]
-        .filter(isHtmlElement)
-        .map(toCommentThreadModel)
-        .filter(isDefined)
-        .sort(compareComments),
-    });
-    this.updateRanges(addedThreadEls, removedThreadEls);
-    addedThreadEls.forEach(threadEl =>
-      this.redispatchHoverEvents(threadEl, threadEl)
-    );
-    // Removed nodes do not need to be handled because all this code does is
-    // adding a slot for the added thread elements, and the extra slots do
-    // not hurt. It's probably a bigger performance cost to remove them than
-    // to keep them around. Medium term we can even consider to add one slot
-    // for each line from the start.
-    for (const threadEl of addedThreadEls) {
-      const lineNum = getLine(threadEl);
-      const commentSide = getSide(threadEl);
-      const range = getRange(threadEl);
-      if (!commentSide) continue;
-      const lineEl = this.getLineElByNumber(lineNum, commentSide);
-      // When the line the comment refers to does not exist, log an error
-      // but don't crash. This can happen e.g. if the API does not fully
-      // validate e.g. (robot) comments
-      if (!lineEl) {
-        console.error(
-          'thread attached to line ',
-          commentSide,
-          lineNum,
-          ' which does not exist.'
-        );
-        continue;
-      }
-      const contentEl = this.getContentTdByLineEl(lineEl);
-      if (!contentEl) continue;
-      if (lineNum === LOST) {
-        this.insertPortedCommentsWithoutRangeMessage(contentEl);
-      }
-
-      const slotAtt = threadEl.getAttribute('slot');
-      if (range && isLongCommentRange(range) && slotAtt) {
-        const longRangeCommentHint = document.createElement(
-          'gr-ranged-comment-hint'
-        );
-        longRangeCommentHint.range = range;
-        longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
-        longRangeCommentHint.setAttribute('slot', slotAtt);
-        this.insertBefore(longRangeCommentHint, threadEl);
-        this.redispatchHoverEvents(longRangeCommentHint, threadEl);
-      }
+  private processNodes() {
+    const threadEls = [...this.childNodes].filter(isThreadEl);
+    const comments = threadEls
+      .map(getDataFromCommentThreadEl)
+      .filter(isDefined)
+      .sort(compareComments);
+    this.diffModel.updateState({comments});
+    this.rangeLayer.updateRanges(comments);
+    for (const el of threadEls) {
+      el.addEventListener('mouseenter', this.commentThreadEnterRedispatcher);
+      el.addEventListener('mouseleave', this.commentThreadLeaveRedispatcher);
     }
-
-    for (const threadEl of removedThreadEls) {
-      this.querySelector(
-        `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-      )?.remove();
-    }
-  }
-
-  private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
-    const existingMessage = lostCell.querySelector('div.lost-message');
-    if (existingMessage) return;
-
-    const div = document.createElement('div');
-    div.className = 'lost-message';
-    const icon = document.createElement('gr-icon');
-    icon.setAttribute('icon', 'info');
-    div.appendChild(icon);
-    const span = document.createElement('span');
-    span.innerText = 'Original comment position not found in this patchset';
-    div.appendChild(span);
-    lostCell.insertBefore(div, lostCell.firstChild);
   }
 
   /** TODO: Can be removed when diff-old is gone. */
@@ -1174,14 +959,6 @@
   }
 }
 
-function extractAddedNodes(mutations: MutationRecord[]) {
-  return mutations.flatMap(mutation => [...mutation.addedNodes]);
-}
-
-function extractRemovedNodes(mutations: MutationRecord[]) {
-  return mutations.flatMap(mutation => [...mutation.removedNodes]);
-}
-
 function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -1217,8 +994,8 @@
     'gr-diff': LitElement;
   }
   interface HTMLElementEventMap {
-    'comment-thread-mouseenter': CustomEvent<{}>;
-    'comment-thread-mouseleave': CustomEvent<{}>;
+    'comment-thread-mouseenter': CustomEvent<GrDiffCommentThread>;
+    'comment-thread-mouseleave': CustomEvent<GrDiffCommentThread>;
     'loading-changed': ValueChangedEvent<boolean>;
     'render-required': CustomEvent<{}>;
     /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 48370411..917decc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -22,7 +22,6 @@
   Side,
 } from '../../../api/diff';
 import {
-  mockPromise,
   mouseDown,
   queryAll,
   stubBaseUrl,
@@ -188,43 +187,6 @@
       assert.isFalse(element.classList.contains('no-left'));
     });
 
-    test('handleTap lineNum', async () => {
-      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element.handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('handleTap content', async () => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-      lineEl.className = 'lineNum';
-      const row = document.createElement('div');
-      row.appendChild(lineEl);
-      row.appendChild(content);
-
-      const selectStub = sinon.stub(element, 'selectLine');
-
-      content.className = 'content';
-      const promise = mockPromise();
-      content.addEventListener('click', e => {
-        element.handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        promise.resolve();
-      });
-      content.click();
-      await promise;
-    });
-
     suite('getCursorStops', () => {
       async function setupDiff() {
         element.diff = createDiff();
@@ -286,26 +248,11 @@
   });
 
   suite('logged in', async () => {
-    let fakeLineEl: HTMLElement;
     setup(async () => {
       element.loggedIn = true;
-
-      fakeLineEl = {
-        getAttribute: sinon.stub().returns(42),
-        classList: {
-          contains: sinon.stub().returns(true),
-        },
-      } as unknown as HTMLElement;
       await element.updateComplete;
     });
 
-    test('addDraftAtLine', () => {
-      sinon.stub(element, 'selectLine');
-      const createCommentStub = sinon.stub(element, 'createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
-    });
-
     test('adds long range comment hint', async () => {
       const range = {
         start_line: 1,
@@ -372,35 +319,6 @@
         1
       );
     });
-
-    test('removes long range comment hint when comment is discarded', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 7,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', '1');
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const content = [
-        {
-          ab: Array(8).fill('text'),
-        },
-      ];
-      await setupSampleDiff({content});
-
-      element.appendChild(threadEl);
-      await waitUntil(() => element.commentRanges.length === 1);
-
-      threadEl.remove();
-      await waitUntil(() => element.commentRanges.length === 0);
-
-      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-    });
   });
 
   suite('blame', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index d7883a0..906c130 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -5,7 +5,7 @@
  */
 import '../gr-range-header/gr-range-header';
 import {CommentRange} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
@@ -32,16 +32,13 @@
   }
 
   override render() {
-    return html`<div class="rangeHighlight row">
-      <gr-range-header icon="mode_comment" filled
-        >${this._computeRangeLabel(this.range)}</gr-range-header
-      >
-    </div>`;
-  }
-
-  _computeRangeLabel(range?: CommentRange): string {
-    if (!range) return '';
-    return `Long comment range ${range.start_line} - ${range.end_line}`;
+    if (!this.range) return nothing;
+    const text = `Long comment range ${this.range.start_line} - ${this.range.end_line}`;
+    return html`
+      <div class="rangeHighlight row">
+        <gr-range-header icon="mode_comment" filled>${text}</gr-range-header>
+      </div>
+    `;
   }
 }
 
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 24729ff9..98acc5a 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
@@ -9,20 +9,22 @@
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
+import {
+  GrDiffCommentThread,
+  isLongCommentRange,
+} from '../gr-diff/gr-diff-utils';
 import {GrDiffLineType} from '../../../api/diff';
 
-/**
- * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
- * outside.
- *
- * TODO(TS): Unify with what is used in gr-diff when these objects are created.
- */
-export interface CommentRangeLayer {
+/** Force `range` to be set for the objects that we are working with. */
+export interface CommentRangeLayer extends Partial<GrDiffCommentThread> {
   side: Side;
   range: CommentRange;
-  // New drafts don't have a rootId.
-  rootId?: string;
+}
+
+function isCommentRangeLayer<T extends Partial<GrDiffCommentThread>>(
+  x: T | CommentRangeLayer | undefined
+): x is CommentRangeLayer {
+  return !!x && !!(x as CommentRangeLayer).range;
 }
 
 /** Can be used for array functions like `some()`. */
@@ -124,7 +126,9 @@
     }
   }
 
-  updateRanges(newRanges: CommentRangeLayer[]) {
+  updateRanges(rawRanges: Partial<GrDiffCommentThread>[]) {
+    const newRanges: CommentRangeLayer[] =
+      rawRanges.filter(isCommentRangeLayer);
     for (const newRange of newRanges) {
       if (this.knownRanges.some(equals(newRange))) continue;
       this.addRange(newRange);
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 5bfd94d..4de6d01 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
@@ -18,6 +18,7 @@
 
 const rangeA: CommentRangeLayer = {
   side: Side.LEFT,
+  line: 39,
   range: {
     end_character: 9,
     end_line: 39,
@@ -29,6 +30,7 @@
 
 const rangeB: CommentRangeLayer = {
   side: Side.RIGHT,
+  line: 12,
   range: {
     end_character: 22,
     end_line: 12,
@@ -40,6 +42,7 @@
 
 const rangeC: CommentRangeLayer = {
   side: Side.RIGHT,
+  line: 100,
   range: {
     end_character: 15,
     end_line: 100,
@@ -50,6 +53,7 @@
 
 const rangeD: CommentRangeLayer = {
   side: Side.RIGHT,
+  line: 55,
   range: {
     end_character: 2,
     end_line: 55,
@@ -61,6 +65,7 @@
 
 const rangeE: CommentRangeLayer = {
   side: Side.RIGHT,
+  line: 71,
   range: {
     end_character: 1,
     end_line: 71,
@@ -71,6 +76,7 @@
 
 const rangeF: CommentRangeLayer = {
   side: Side.RIGHT,
+  line: 24,
   range: {
     end_character: 0,
     end_line: 24,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 06f6321..a0360ef 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1147,7 +1147,6 @@
       undefined,
       listChangesOptionsToHex(
         ListChangesOption.CHANGE_ACTIONS,
-        ListChangesOption.CURRENT_ACTIONS,
         ListChangesOption.CURRENT_REVISION,
         ListChangesOption.DETAILED_LABELS,
         // TODO: remove this option and merge requirements from dashboard req
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index 764aa98..696e142 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -67,7 +67,9 @@
 
 const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
   ListChangesOption.CHANGE_ACTIONS,
-  ListChangesOption.CURRENT_ACTIONS,
+  // Current actions can be costly to calculate (e.g submit action)
+  // They are not used in bulk actions.
+  // ListChangesOption.CURRENT_ACTIONS,
   ListChangesOption.CURRENT_REVISION,
   ListChangesOption.DETAILED_LABELS,
   ListChangesOption.SUBMIT_REQUIREMENTS
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 73ee6aa..da6a92d 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -53,6 +53,6 @@
     highlightServiceToken,
     () => new MockHighlightService(appContext.reportingService)
   );
-  dependencies.set(diffModelToken, () => new DiffModel());
+  dependencies.set(diffModelToken, () => new DiffModel(document));
   return dependencies;
 }
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
index 28c22e5..0d52bd7 100644
--- a/polygerrit-ui/app/utils/diff-util.ts
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -10,6 +10,10 @@
 // syntax highlighting for the entire file.
 export const SYNTAX_MAX_LINE_LENGTH = 500;
 
+export function otherSide(side: Side) {
+  return side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+}
+
 export function countLines(diff?: DiffInfo, side?: Side) {
   if (!diff?.content || !side) return 0;
   return diff.content.reduce((sum, chunk) => {
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 7d4499a..592e4a7 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -3,7 +3,7 @@
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
 COMMONMARK_VERSION = "0.21.0"
-FLEXMARK_VERS = "0.50.50"
+FLEXMARK_VERS = "0.64.0"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
@@ -191,157 +191,183 @@
     maven_jar(
         name = "gfm-tables",
         artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+        attach_source = False,
         sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
     )
 
     maven_jar(
         name = "flexmark",
         artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-        sha1 = "7f61e190cff7e1bea64906408ccfa583b5ac9fc2",
+        sha1 = "bb5fcdf1335a35c4c0285fee2683a32e6a70cd59",
     )
 
     maven_jar(
         name = "flexmark-ext-abbreviation",
         artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-        sha1 = "8f62f49cfaf8d33391e48a3b79e941d94e444e50",
+        sha1 = "48a64d5d1e5d1e0f8efde75f051b16800952eeb3",
     )
 
     maven_jar(
         name = "flexmark-ext-anchorlink",
         artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-        sha1 = "1d530e44b4439abf53ce9dcc784ffa5b9fd6ce89",
+        sha1 = "e263a138896153bad6b69828d65c409fd99292a8",
     )
 
     maven_jar(
         name = "flexmark-ext-autolink",
         artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-        sha1 = "4026aa60fd6e146c2d4931acb19b2409c6a79b8a",
+        sha1 = "e380ee48d5a779d08f071aaaf9a69e5985f24802",
     )
 
     maven_jar(
         name = "flexmark-ext-definition",
         artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-        sha1 = "bb74d36459e8e34653761aaa0095220b8b7b6cb6",
+        sha1 = "892d83daafb1255489377a4331c6b04cd9fef288",
     )
 
     maven_jar(
         name = "flexmark-ext-emoji",
         artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-        sha1 = "36d36cb227f831b81b636d3f901f07db06b8d84d",
+        sha1 = "63777b6b82456fb08587537db949c67ac6ac5d66",
     )
 
     maven_jar(
         name = "flexmark-ext-escaped-character",
         artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-        sha1 = "09f0cef3260b6f6371d6066cf32ce6a7dc2a7922",
+        sha1 = "19b48848dd0d242c8dd527113cf91711847cf7f1",
     )
 
     maven_jar(
         name = "flexmark-ext-footnotes",
         artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-        sha1 = "cdf0b79f026b09c6b87d91e61eb29ada1577aa5c",
+        sha1 = "a885e4c512f6f73b3ea02a26f71e0116e720b33e",
     )
 
     maven_jar(
         name = "flexmark-ext-gfm-issues",
         artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-        sha1 = "e40d347e242e35d4344553120cb9e69c1c975f41",
+        sha1 = "f1f2e840cd295bfaad006e7bddb715edfd747e44",
     )
 
     maven_jar(
         name = "flexmark-ext-gfm-strikethrough",
         artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-        sha1 = "a6948f6e4fc2ec1059d6b53e73d4a30c24f6e05d",
-    )
-
-    maven_jar(
-        name = "flexmark-ext-gfm-tables",
-        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-        sha1 = "92d3eb0c5dc79924448c186d89717f1df853aaa0",
+        sha1 = "9e4d01143752ebe98d45ac29c77e17fa8cc8b947",
     )
 
     maven_jar(
         name = "flexmark-ext-gfm-tasklist",
         artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-        sha1 = "e6fb4d9e46c96e61a07fbb40cf653db58ed95a95",
+        sha1 = "ad5d3dda8a17100ee01606386aacb73d41e35764",
     )
 
     maven_jar(
         name = "flexmark-ext-gfm-users",
         artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-        sha1 = "44c83bdccfd41a399f3f7b11ba72728382dd3f5a",
+        sha1 = "59846560518f6ef5282d333e1ba1a68dc5ea8c43",
     )
 
     maven_jar(
         name = "flexmark-ext-ins",
         artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-        sha1 = "b0d174d86ac2348420993dc2c997fad5f02c68fa",
+        sha1 = "b5a7e72e337d31e2956f662b37ec1f47289e7751",
     )
 
     maven_jar(
         name = "flexmark-ext-jekyll-front-matter",
         artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-        sha1 = "2bfb31c67fd10af058b90f1b364f881f6276de80",
+        sha1 = "5543c826f325444edacc6920992753568aa046f6",
     )
 
     maven_jar(
         name = "flexmark-ext-superscript",
         artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-        sha1 = "d210b007b46339082f79b6caf632b19ac8efc341",
+        sha1 = "bcee70d603fb7acf9ef490036b14a5c1cb3f533c",
     )
 
     maven_jar(
         name = "flexmark-ext-tables",
         artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-        sha1 = "be77790470aa9bd90011067221504162a1d7b083",
+        sha1 = "52b829753bb928cd3f26c43d39639eb95b3f9f88",
     )
 
     maven_jar(
         name = "flexmark-ext-toc",
         artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-        sha1 = "91d6d63ff5b70c3ebae98867bd7a346d71cb0ce1",
+        sha1 = "69b96da85758688a564babea371fc9268a11d718",
     )
 
     maven_jar(
         name = "flexmark-ext-typographic",
         artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-        sha1 = "f51e247df80628df509a3af92a1efa1efb83d746",
+        sha1 = "5e2cb29da6c43eecffbd34a4f69976f61a383e97",
     )
 
     maven_jar(
         name = "flexmark-ext-wikilink",
         artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-        sha1 = "d14aeb078dbaf3e166621ae9499595a4a57b22ab",
+        sha1 = "5ada81fecf0d68cdcd5c763da0f5f22aa885d559",
     )
 
     maven_jar(
         name = "flexmark-ext-yaml-front-matter",
         artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-        sha1 = "aba328b70e15f2553aac0a74e391edab37bf7b30",
-    )
-
-    maven_jar(
-        name = "flexmark-formatter",
-        artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-        sha1 = "3e4ad3b1be29d41e8c35cadb70761768505f2164",
-    )
-
-    maven_jar(
-        name = "flexmark-html-parser",
-        artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-        sha1 = "3c59b0061c50c9a8a48c46d4f4498d8eba249921",
+        sha1 = "377b29278cad7603aa3ca705c31a9f48cb6f8fb9",
     )
 
     maven_jar(
         name = "flexmark-profile-pegdown",
         artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-        sha1 = "c01a2c2eebe06230858956d45847cee233790d7c",
+        sha1 = "c67446ffd3653416ed26f924ba99f8125fb74791",
     )
 
     maven_jar(
-        name = "flexmark-util",
-        artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-        sha1 = "fdfce72f5eb9ec53085804fd0c8d15f3680ae359",
+        name = "flexmark-util-data",
+        artifact = "com.vladsch.flexmark:flexmark-util-data:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "63162607faa7c98f6e50a9d1e826004c5475841d",
+    )
+
+    maven_jar(
+        name = "flexmark-util-ast",
+        artifact = "com.vladsch.flexmark:flexmark-util-ast:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "10fff87e61b7c3bb12afddf7b418977cd02acdc8",
+    )
+
+    maven_jar(
+        name = "flexmark-util-misc",
+        artifact = "com.vladsch.flexmark:flexmark-util-misc:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "d2129f4f2b55fbf645e3499c7b0cdddcfef81112",
+    )
+
+    maven_jar(
+        name = "flexmark-util-visitor",
+        artifact = "com.vladsch.flexmark:flexmark-util-visitor:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "7eb0030ae2a2eec80d74790caeb4cdb6b4bf8e17",
+    )
+
+    maven_jar(
+        name = "flexmark-util-builder",
+        artifact = "com.vladsch.flexmark:flexmark-util-builder:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "2d6adaf6053f72de86a050fe6967049f7c2d3500",
+    )
+
+    maven_jar(
+        name = "flexmark-util-sequence",
+        artifact = "com.vladsch.flexmark:flexmark-util-sequence:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "299e3b4a4272ba9dcd6b9081b8d24d1a672a0921",
+    )
+
+    maven_jar(
+        name = "flexmark-util-html",
+        artifact = "com.vladsch.flexmark:flexmark-util-html:" + FLEXMARK_VERS,
+        attach_source = False,
+        sha1 = "fc52f860cf45f57468d8f14ee63c1e6469ee3f47",
     )
 
     # Transitive dependency of flexmark and gitiles