Merge "ListOfFilesUnchangedPredicate: Ignore due to rebase file modifications"
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 3d3603f..ba9f6d6 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -450,6 +450,10 @@
   /**
    * Get NoteDb draft refs for a change.
    *
+   * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
+   * comments. A zombie draft is one which has been published but the write to delete the draft ref
+   * from All-Users failed.
+   *
    * @param changeId change ID.
    * @return raw refs from All-Users repo.
    */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index f2034af..2d9b014 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -32,6 +32,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
@@ -522,7 +523,12 @@
   public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
-    return draftCommentNotes.getComments();
+    // Filter out any zombie draft comments. These are drafts that are also in
+    // the published map, and arise when the update to All-Users to delete them
+    // during the publish operation failed.
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(
+            draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
   }
 
   public ImmutableListMultimap<ObjectId, RobotComment> getRobotComments() {
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index 57a3cd7..1a7d5af 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -50,7 +50,9 @@
     // patchset to the user before it was merged.
     ChangeData changeData = changeDataFactory.create(ctx.getProject(), ctx.getChange().getId());
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.putSubmitRequirementResults(evaluator.evaluateAllRequirements(changeData).values());
+    // We do not want to store submit requirements in NoteDb for legacy submit records
+    update.putSubmitRequirementResults(
+        evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false).values());
     return !changeData.submitRequirements().isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index f028def..63a29cc 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -25,8 +25,12 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -38,7 +42,25 @@
 
   private SubmitRequirementsAdapter() {}
 
-  public static List<SubmitRequirementResult> createResult(
+  /**
+   * Retrieve legacy submit records (created by label functions and other {@link
+   * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
+   */
+  public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
+      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+    // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
+    // This doesn't have an effect since we never call this class (i.e. to evaluate submit
+    // requirements) for closed changes.
+    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    ObjectId commitId = cd.currentPatchSet().commitId();
+    return records.stream()
+        .map(r -> createResult(r, labelTypes, commitId))
+        .flatMap(List::stream)
+        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
+  }
+
+  static List<SubmitRequirementResult> createResult(
       SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId) {
     List<SubmitRequirementResult> results;
     if (record.ruleName.equals("gerrit~DefaultSubmitRule")) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index b3ac380..402bb51 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -26,8 +26,13 @@
   /**
    * Evaluate and return all submit requirement results for a change. Submit requirements are read
    * from the project config of the project containing the change as well as parent projects.
+   *
+   * @param cd change data corresponding to a specific gerrit change
+   * @param includeLegacy if set to true, evaluate legacy {@link
+   *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(ChangeData cd);
+  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+      ChangeData cd, boolean includeLegacy);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
   SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 9be50c7..151ee7b 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -17,8 +17,6 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
@@ -36,12 +34,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.lib.ObjectId;
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
@@ -81,12 +75,14 @@
   }
 
   @Override
-  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(ChangeData cd) {
+  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+      ChangeData cd, boolean includeLegacy) {
     Map<SubmitRequirement, SubmitRequirementResult> result = getRequirements(cd);
-    if (experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
-      result.putAll(getLegacyRequirements(cd));
+    if (includeLegacy
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
+      result.putAll(SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd));
     }
     return ImmutableMap.copyOf(result);
   }
@@ -140,23 +136,6 @@
     return result;
   }
 
-  /**
-   * Convert and return legacy submit records (created by label functions and other {@link
-   * com.google.gerrit.server.rules.SubmitRule}s to submit requirement results.
-   */
-  private Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(ChangeData cd) {
-    // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
-    // This doesn't have an effect since we never call this class (i.e. to evaluate submit
-    // requirements) for closed changes.
-    List<SubmitRecord> records = legacyEvaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
-    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
-    ObjectId commitId = cd.currentPatchSet().commitId();
-    return records.stream()
-        .map(r -> SubmitRequirementsAdapter.createResult(r, labelTypes, commitId))
-        .flatMap(List::stream)
-        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
-  }
-
   /** Evaluate the predicate recursively using change data. */
   private PredicateResult evaluatePredicateTree(
       Predicate<ChangeData> predicate, ChangeData changeData) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index c551cd2..4112579 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -75,6 +75,8 @@
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -87,6 +89,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementsAdapter;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -268,7 +271,7 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, project, id, null, null);
+            null, null, null, project, id, null, null);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -286,6 +289,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
+  private final ExperimentFeatures experimentFeatures;
   private final GitRepositoryManager repoManager;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
@@ -367,6 +371,7 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
+      ExperimentFeatures experimentFeatures,
       GitRepositoryManager repoManager,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
@@ -386,6 +391,7 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
+    this.experimentFeatures = experimentFeatures;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
@@ -946,13 +952,33 @@
         return Collections.emptyMap();
       }
       Change c = change();
-      if (c != null && c.isClosed()) {
+      if (c == null || !c.isClosed()) {
+        // Open changes: Evaluate submit requirements online.
         submitRequirements =
-            notes().getSubmitRequirementsResult().stream()
-                .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
-      } else {
-        submitRequirements = submitRequirementsEvaluator.evaluateAllRequirements(this);
+            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ true);
+        return submitRequirements;
       }
+      // Closed changes: Load submit requirement results from NoteDb.
+      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+          notes().getSubmitRequirementsResult().stream()
+              .filter(r -> !r.legacy())
+              .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
+      if (!experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
+        submitRequirements = projectConfigRequirements;
+        return submitRequirements;
+      }
+      // Get legacy submit requirements, i.e. those created from submit records.
+      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
+          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
+      // Combine projectConfigRequirements with legacyRequirements
+      submitRequirements =
+          Stream.of(projectConfigRequirements, legacyRequirements)
+              .flatMap(map -> map.entrySet().stream())
+              .collect(
+                  ImmutableMap.toImmutableMap(
+                      Map.Entry::getKey, Map.Entry::getValue, (value1, value2) -> value1));
     }
     return submitRequirements;
   }
@@ -1347,7 +1373,14 @@
       draftsByUser = new HashMap<>();
       for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
         Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-        if (account != null) {
+        if (account != null
+            // Double-check that any drafts exist for this user after
+            // filtering out zombies. If some but not all drafts in the ref
+            // were zombies, the returned Ref still includes those zombies;
+            // this is suboptimal, but is ok for the purposes of
+            // draftsByUser(), and easier than trying to rebuild the change at
+            // this point.
+            && !notes().getDraftComments(account, ref).isEmpty()) {
           draftsByUser.put(account, ref.getObjectId());
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 4c17800..ed9f2f3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4582,6 +4582,7 @@
     // 4. Merge the change. Submit requirements status is presented from NoteDb.
     gApi.changes().id(changeId).current().submit();
     change = gApi.changes().id(changeId).get();
+    // Legacy submit records are returned as submit requirements.
     assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 4e7b3f3..c524c94 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -76,6 +76,8 @@
 import org.junit.Test;
 
 public class ChangeNotesTest extends AbstractChangeNotesTest {
+  @Inject private DraftCommentNotes.Factory draftNotesFactory;
+
   @Inject private ChangeNoteJson changeNoteJson;
 
   @Test
@@ -2978,6 +2980,86 @@
   }
 
   @Test
+  public void filterOutAndFixUpZombieDraftComments() throws Exception {
+    Change c = newChange();
+    ObjectId commitId1 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    HumanComment comment1 =
+        newComment(
+            ps1,
+            "file1",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            commitId1,
+            false);
+    HumanComment comment2 =
+        newComment(
+            ps1,
+            "file2",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "another comment",
+            side,
+            commitId1,
+            false);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
+    update.commit();
+
+    String refName = refsDraftComments(c.getId(), otherUserId);
+    ObjectId oldDraftId = exactRefAllUsers(refName);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
+    update.commit();
+    assertThat(exactRefAllUsers(refName)).isNotNull();
+    assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
+
+    // Re-add draft version of comment2 back to draft ref without updating
+    // change ref. Simulates the case where deleting the draft failed
+    // non-atomically after adding the published comment succeeded.
+    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
+    draftUpdate.putComment(comment2);
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
+      manager.add(draftUpdate);
+      manager.execute();
+    }
+
+    // Looking at drafts directly shows the zombie comment.
+    DraftCommentNotes draftNotes = draftNotesFactory.create(c.getId(), otherUserId);
+    assertThat(draftNotes.load().getComments().get(commitId1)).containsExactly(comment1, comment2);
+
+    // Zombie comment is filtered out of drafts via ChangeNotes.
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
+    update.commit();
+
+    // Updating an unrelated comment causes the zombie comment to get fixed up.
+    assertThat(exactRefAllUsers(refName)).isNull();
+  }
+
+  @Test
   public void updateCommentsInSequentialUpdates() throws Exception {
     Change c = newChange();
     CommentRange range = new CommentRange(1, 1, 2, 1);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 2663853..9ebee9c 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -69,6 +69,7 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -2333,6 +2334,44 @@
   }
 
   @Test
+  public void byHasDraftExcludesZombieDrafts() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    Change.Id id = change.getId();
+
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(id.get()).current().createDraft(in);
+
+    assertQuery("has:draft", change);
+    assertQuery("commentby:" + userId);
+
+    try (TestRepository<Repo> allUsers =
+        new TestRepository<>(repoManager.openRepository(allUsersName))) {
+      Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
+      assertThat(draftsRef).isNotNull();
+
+      ReviewInput rin = ReviewInput.dislike();
+      rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+      gApi.changes().id(id.get()).current().review(rin);
+
+      assertQuery("has:draft");
+      assertQuery("commentby:" + userId, change);
+      assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
+
+      // Re-add drafts ref and ensure it gets filtered out during indexing.
+      allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
+      assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
+    }
+
+    indexer.index(project, id);
+    assertQuery("has:draft");
+  }
+
+  @Test
   public void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index ba62ec3..8b46cd8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -752,6 +752,16 @@
           })
       );
   }
+
+  _showNewSubmitRequirements(change?: ParsedChangeInfo) {
+    if (!this._isSubmitRequirementsUiEnabled) return false;
+    return (change?.submit_requirements ?? []).length > 0;
+  }
+
+  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
+    if (!this._isSubmitRequirementsUiEnabled) return false;
+    return (change?.submit_requirements ?? []).length === 0;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 26d1277..97101f6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -113,6 +113,10 @@
       --iron-icon-height: 18px;
       --iron-icon-width: 18px;
     }
+    .submit-requirement-error {
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
     <div class="metadata-header">
@@ -480,20 +484,25 @@
       </span>
     </section>
     <div class="separatedSection">
-      <template is="dom-if" if="[[_isSubmitRequirementsUiEnabled]]">
+      <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
         <gr-submit-requirements
           change="[[change]]"
           account="[[account]]"
           mutable="[[_mutable]]"
         ></gr-submit-requirements>
       </template>
-      <template is="dom-if" if="[[!_isSubmitRequirementsUiEnabled]]">
+      <template is="dom-if" if="[[!_showNewSubmitRequirements(change)]]">
         <gr-change-requirements
           change="{{change}}"
           account="[[account]]"
           mutable="[[_mutable]]"
         ></gr-change-requirements>
       </template>
+      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
+        <div class="submit-requirement-error">
+          New Submit Requirements don't work on this change.
+        </div>
+      </template>
     </div>
     <section
       id="webLinks"
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 2e00034..4b1dba6 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 import '../../../styles/gr-font-styles';
-import '../../shared/gr-hovercard/gr-hovercard-shared-style';
+import '../../../styles/gr-hovercard-styles';
+import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
index b7b4d9c..5023895 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
@@ -20,7 +20,10 @@
   <style include="gr-font-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="gr-hovercard-shared-style">
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-hovercard-styles">
     #container {
       min-width: 356px;
       max-width: 356px;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 48ccf2c..9c27cdb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -293,7 +293,13 @@
   override firstUpdated() {
     const loading = this.shadowRoot?.querySelector('.container');
     assertIsDefined(loading, '"Loading" element');
-    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+    whenVisible(
+      loading,
+      () => {
+        this.shouldRender = true;
+      },
+      200
+    );
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index d26856c..57eac3b 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -16,6 +16,8 @@
  */
 import './gr-checks-styles';
 import '../../styles/gr-font-styles';
+import '../../styles/gr-hovercard-styles';
+import '../../styles/shared-styles';
 import {HovercardBehaviorMixin} from '../shared/gr-hovercard/gr-hovercard-behavior';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-run_html';
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
index 49a1416..52dbb9c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -23,7 +23,10 @@
   <style include="gr-checks-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="gr-hovercard-shared-style">
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-hovercard-styles">
     #container {
       min-width: 356px;
       max-width: 356px;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 4641897..b1bad1c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -56,6 +56,7 @@
   PatchRange,
   PatchSetNum,
   RepoName,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {
   DiffInfo,
@@ -730,11 +731,24 @@
   }
 
   _threadsChanged(threads: CommentThread[]) {
-    const threadEls = new Set<Object>();
+    const threadEls = new Set<GrCommentThread>();
+    const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
+    for (const threadEl of this.getThreadEls()) {
+      if (threadEl.rootId) {
+        rootIdToThreadEl.set(threadEl.rootId, threadEl);
+      }
+    }
     for (const thread of threads) {
-      const threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
-      threadEls.add(threadEl);
+      const existingThreadEl =
+        thread.rootId && rootIdToThreadEl.get(thread.rootId);
+      if (existingThreadEl) {
+        this._updateThreadElement(existingThreadEl, thread);
+        threadEls.add(existingThreadEl);
+      } else {
+        const threadEl = this._createThreadElement(thread);
+        this._attachThreadElement(threadEl);
+        threadEls.add(threadEl);
+      }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index ed3ffe0..8901636 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -1145,11 +1145,19 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-
       element.threads= [...element.threads, thread];
 
       threads = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread');
+      // Threads have same rootId so element is reused
+      assert.equal(threads.length, 1);
+
+      const newThread = {...thread};
+      newThread.rootId = 'differentRootId';
+      element.threads= [...element.threads, newThread];
+      threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      // New thread has a different rootId
       assert.equal(threads.length, 2);
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 3a05e7d..32f5f39 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -422,25 +422,23 @@
       />
     `;
 
-    const sourceImageWithHighlight = this.diffHighlightSrc
-      ? html`
-          <div id="source-plus-highlight-container">
-            ${sourceImage}
-            <img
-              id="highlight-image"
-              style="${styleMap({
-                opacity: this.showHighlight ? '1' : '0',
-                // When the highlight layer is not being shown, saving the image or
-                // opening it in a new tab from the context menu, e.g. for external
-                // comparison, should give back the source image, not the highlight
-                // layer.
-                'pointer-events': this.showHighlight ? 'auto' : 'none',
-              })}"
-              src="${this.diffHighlightSrc}"
-            />
-          </div>
-        `
-      : '';
+    const sourceImageWithHighlight = html`
+      <div id="source-plus-highlight-container">
+        ${sourceImage}
+        <img
+          id="highlight-image"
+          style="${styleMap({
+            opacity: this.showHighlight ? '1' : '0',
+            // When the highlight layer is not being shown, saving the image or
+            // opening it in a new tab from the context menu, e.g. for external
+            // comparison, should give back the source image, not the highlight
+            // layer.
+            'pointer-events': this.showHighlight ? 'auto' : 'none',
+          })}"
+          src="${this.diffHighlightSrc}"
+        />
+      </div>
+    `;
 
     const versionExplanation = html`
       <div id="version-explanation">
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 06272ed..3988095 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -18,6 +18,7 @@
 import '@polymer/iron-icon/iron-icon';
 import '../../../styles/gr-font-styles';
 import '../../../styles/shared-styles';
+import '../../../styles/gr-hovercard-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
 import {HovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index adca888..cba7293 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -14,14 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-hovercard/gr-hovercard-shared-style';
+import '../../../styles/gr-hovercard-styles';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
   <style include="gr-font-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <style include="gr-hovercard-shared-style">
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-hovercard-styles">
     .top,
     .attention,
     .status,
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 82af365..3d8702b 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -129,12 +129,8 @@
       super.connectedCallback();
       if (!this._target) {
         this._target = this.target;
+        this.addTargetEventListeners();
       }
-      this._target.addEventListener('mouseenter', this.debounceShow);
-      this._target.addEventListener('focus', this.debounceShow);
-      this._target.addEventListener('mouseleave', this.debounceHide);
-      this._target.addEventListener('blur', this.debounceHide);
-      this._target.addEventListener('click', this.hide);
 
       // show the hovercard if mouse moves to hovercard
       // this will cancel pending hide as well
@@ -149,12 +145,23 @@
       this.cancelShowTask();
       this.cancelHideTask();
       this.unlock();
+      super.disconnectedCallback();
+    }
+
+    addTargetEventListeners() {
+      this._target?.addEventListener('mouseenter', this.debounceShow);
+      this._target?.addEventListener('focus', this.debounceShow);
+      this._target?.addEventListener('mouseleave', this.debounceHide);
+      this._target?.addEventListener('blur', this.debounceHide);
+      this._target?.addEventListener('click', this.hide);
+    }
+
+    removeTargetEventListeners() {
       this._target?.removeEventListener('mouseenter', this.debounceShow);
       this._target?.removeEventListener('focus', this.debounceShow);
       this._target?.removeEventListener('mouseleave', this.debounceHide);
       this._target?.removeEventListener('blur', this.debounceHide);
       this._target?.removeEventListener('click', this.hide);
-      super.disconnectedCallback();
     }
 
     override ready() {
@@ -457,7 +464,9 @@
      */
     @observe('for')
     _forChanged() {
+      this.removeTargetEventListeners();
       this._target = this.target;
+      this.addTargetEventListeners();
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
deleted file mode 100644
index aa92654..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-/** The shared styles for all hover cards. */
-const GrHoverCardSharedStyle = document.createElement('dom-module');
-GrHoverCardSharedStyle.innerHTML = `<template>
-    <style include="shared-styles">
-      :host {
-        position: absolute;
-        display: none;
-        z-index: 200;
-        max-width: 600px;
-        outline: none;
-      }
-      :host(.hovered) {
-        display: block;
-      }
-      :host(.hide) {
-        visibility: hidden;
-      }
-      /* You have to use a <div class="container"> in your hovercard in order
-         to pick up this consistent styling. */
-      #container {
-        background: var(--dialog-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-shadow: var(--elevation-level-5);
-      }
-    </style>
-  </template>`;
-
-GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
index acc5e15..5fc53e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -15,20 +15,35 @@
  * limitations under the License.
  */
 
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-hovercard_html';
-import {HovercardBehaviorMixin} from './gr-hovercard-behavior';
-import './gr-hovercard-shared-style';
-import {customElement} from '@polymer/decorators';
+import {customElement} from 'lit/decorators';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {css, html, LitElement} from 'lit';
+import {hovercardStyles} from '../../../styles/gr-hovercard-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = HovercardBehaviorMixin(PolymerElement);
+const base = HovercardMixin(LitElement);
 
 @customElement('gr-hovercard')
 export class GrHovercard extends base {
-  static get template() {
-    return htmlTemplate;
+  static override get styles() {
+    return [
+      sharedStyles,
+      hovercardStyles,
+      css`
+        #container {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="container" role="tooltip" tabindex="-1">
+        <slot></slot>
+      </div>
+    `;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
deleted file mode 100644
index 830cbd878..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-hovercard-shared-style">
-    #container {
-      padding: var(--spacing-l);
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
deleted file mode 100644
index d5e0061..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-hovercard.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard for="foo" id="bar"></gr-hovercard>
-`);
-
-suite('gr-hovercard tests', () => {
-  let element;
-
-  let button;
-  let testResolve;
-  let testPromise;
-
-  setup(() => {
-    testResolve = undefined;
-    testPromise = new Promise(r => testResolve = r);
-    button = document.createElement('button');
-    button.innerHTML = 'Hello';
-    button.setAttribute('id', 'foo');
-    document.body.appendChild(button);
-
-    element = basicFixture.instantiate();
-  });
-
-  teardown(() => {
-    element.hide({});
-    button.remove();
-  });
-
-  test('updatePosition', () => {
-    // Test that the correct style properties have at least been set.
-    element.position = 'bottom';
-    element.updatePosition();
-    assert.typeOf(element.style.getPropertyValue('left'), 'string');
-    assert.typeOf(element.style.getPropertyValue('top'), 'string');
-    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
-    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
-    const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = element._target.getBoundingClientRect();
-    const thisRect = element.getBoundingClientRect();
-
-    const targetLeft = targetRect.left - parentRect.left;
-    const targetTop = targetRect.top - parentRect.top;
-
-    const pixelCompare = pixel =>
-      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
-    assert.equal(
-        pixelCompare(element.style.left),
-        pixelCompare(
-            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
-    assert.equal(
-        pixelCompare(element.style.top),
-        pixelCompare(
-            (targetTop + targetRect.height + element.offset) + 'px'));
-  });
-
-  test('hide', () => {
-    element.hide({});
-    const style = getComputedStyle(element);
-    assert.isFalse(element._isShowing);
-    assert.isFalse(element.classList.contains('hovered'));
-    assert.equal(style.display, 'none');
-    assert.notEqual(element.container, element.parentNode);
-  });
-
-  test('show', async () => {
-    await element.show({});
-    const style = getComputedStyle(element);
-    assert.isTrue(element._isShowing);
-    assert.isTrue(element.classList.contains('hovered'));
-    assert.equal(style.opacity, '1');
-    assert.equal(style.visibility, 'visible');
-  });
-
-  test('debounceShow does not show immediately', async () => {
-    element.debounceShowBy(100);
-    setTimeout(testResolve, 0);
-    await testPromise;
-    assert.isFalse(element._isShowing);
-  });
-
-  test('debounceShow shows after delay', async () => {
-    element.debounceShowBy(1);
-    setTimeout(testResolve, 10);
-    await testPromise;
-    assert.isTrue(element._isShowing);
-  });
-
-  test('card is scheduled to show on enter and hides on leave', async () => {
-    const button = document.querySelector('button');
-    let enterResolve = undefined;
-    const enterPromise = new Promise(r => enterResolve = r);
-    button.addEventListener('mouseenter', enterResolve);
-    let leaveResolve = undefined;
-    const leavePromise = new Promise(r => leaveResolve = r);
-    button.addEventListener('mouseleave', leaveResolve);
-
-    assert.isFalse(element._isShowing);
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-
-    await enterPromise;
-    await flush();
-    assert.isTrue(element.isScheduledToShow);
-    element.showTask.flush();
-    assert.isTrue(element._isShowing);
-    assert.isFalse(element.isScheduledToShow);
-
-    button.dispatchEvent(new CustomEvent('mouseleave'));
-
-    await leavePromise;
-    assert.isTrue(element.isScheduledToHide);
-    assert.isTrue(element._isShowing);
-    element.hideTask.flush();
-    assert.isFalse(element.isScheduledToShow);
-    assert.isFalse(element._isShowing);
-
-    button.removeEventListener('mouseenter', enterResolve);
-    button.removeEventListener('mouseleave', leaveResolve);
-  });
-
-  test('card should disappear on click', async () => {
-    const button = document.querySelector('button');
-    let enterResolve = undefined;
-    const enterPromise = new Promise(r => enterResolve = r);
-    button.addEventListener('mouseenter', enterResolve);
-    let clickResolve = undefined;
-    const clickPromise = new Promise(r => clickResolve = r);
-    button.addEventListener('click', clickResolve);
-
-    assert.isFalse(element._isShowing);
-
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-
-    await enterPromise;
-    await flush();
-    assert.isTrue(element.isScheduledToShow);
-    MockInteractions.tap(button);
-
-    await clickPromise;
-    assert.isFalse(element.isScheduledToShow);
-    assert.isFalse(element._isShowing);
-
-    button.removeEventListener('mouseenter', enterResolve);
-    button.removeEventListener('click', clickResolve);
-  });
-});
-
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
new file mode 100644
index 0000000..793e5d6
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -0,0 +1,488 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {getRootElement} from '../../scripts/rootElement';
+import {Constructor} from '../../utils/common-util';
+import {LitElement, PropertyValues} from 'lit';
+import {property, query} from 'lit/decorators';
+import {ShowAlertEventDetail} from '../../types/events';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {hovercardStyles} from '../../styles/gr-hovercard-styles';
+import {sharedStyles} from '../../styles/shared-styles';
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
+const HOVER_CLASS = 'hovered';
+const HIDE_CLASS = 'hide';
+
+/**
+ * ID for the container element.
+ */
+const containerId = 'gr-hovercard-container';
+
+export function getHovercardContainer(
+  options: {createIfNotExists: boolean} = {createIfNotExists: false}
+): HTMLElement | null {
+  let container = getRootElement().querySelector<HTMLElement>(
+    `#${containerId}`
+  );
+  if (!container && options.createIfNotExists) {
+    // If it does not exist, create and initialize the hovercard container.
+    container = document.createElement('div');
+    container.setAttribute('id', containerId);
+    getRootElement().appendChild(container);
+  }
+  return container;
+}
+
+/**
+ * How long should we wait before showing the hovercard when the user hovers
+ * over the element?
+ */
+const SHOW_DELAY_MS = 550;
+
+/**
+ * How long should we wait before hiding the hovercard when the user moves from
+ * target to the hovercard.
+ *
+ * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
+ */
+const HIDE_DELAY_MS = 500;
+
+/**
+ * The mixin for hovercard behavior.
+ *
+ * @example
+ *
+ * class YourComponent extends hovercardBehaviorMixin(
+ *  LitElement)
+ *
+ * @see gr-hovercard.ts
+ *
+ * // following annotations are required for polylint
+ * @lit
+ * @mixinFunction
+ */
+export const HovercardMixin = <T extends Constructor<LitElement>>(
+  superClass: T
+) => {
+  /**
+   * @lit
+   * @mixinClass
+   */
+  class Mixin extends superClass {
+    @query('#container')
+    topElement?: HTMLElement;
+
+    @property({type: Object})
+    _target: HTMLElement | null = null;
+
+    // Determines whether or not the hovercard is visible.
+    @property({type: Boolean})
+    _isShowing = false;
+
+    // The `id` of the element that the hovercard is anchored to.
+    @property({type: String})
+    for?: string;
+
+    /**
+     * The spacing between the top of the hovercard and the element it is
+     * anchored to.
+     */
+    @property({type: Number})
+    offset = 14;
+
+    /**
+     * Positions the hovercard to the top, right, bottom, left, bottom-left,
+     * bottom-right, top-left, or top-right of its content.
+     */
+    @property({type: String})
+    position = 'right';
+
+    @property({type: Object})
+    container: HTMLElement | null = null;
+
+    // Private but used in tests.
+    hideTask?: DelayedTask;
+
+    showTask?: DelayedTask;
+
+    isScheduledToShow?: boolean;
+
+    isScheduledToHide?: boolean;
+
+    static get styles() {
+      return [sharedStyles, hovercardStyles];
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    constructor(...args: any[]) {
+      super(...args);
+      // show the hovercard if mouse moves to hovercard
+      // this will cancel pending hide as well
+      this.addEventListener('mouseenter', this.show);
+      // when leave hovercard, hide it immediately
+      this.addEventListener('mouseleave', this.hide);
+    }
+
+    override connectedCallback() {
+      super.connectedCallback();
+      // We have to cache the target because when we this.container.appendChild
+      // in show we can not pick the container as target when we reconnect.
+      if (!this._target) {
+        this._target = this.target;
+        this.addTargetEventListeners();
+      }
+
+      this.container = getHovercardContainer({createIfNotExists: true});
+    }
+
+    override disconnectedCallback() {
+      this.cancelShowTask();
+      this.cancelHideTask();
+      super.disconnectedCallback();
+    }
+
+    private addTargetEventListeners() {
+      this._target?.addEventListener('mouseenter', this.debounceShow);
+      this._target?.addEventListener('focus', this.debounceShow);
+      this._target?.addEventListener('mouseleave', this.debounceHide);
+      this._target?.addEventListener('blur', this.debounceHide);
+      this._target?.addEventListener('click', this.hide);
+    }
+
+    private removeTargetEventListeners() {
+      this._target?.removeEventListener('mouseenter', this.debounceShow);
+      this._target?.removeEventListener('focus', this.debounceShow);
+      this._target?.removeEventListener('mouseleave', this.debounceHide);
+      this._target?.removeEventListener('blur', this.debounceHide);
+      this._target?.removeEventListener('click', this.hide);
+    }
+
+    /**
+     * Responds to a change in the `for` value and gets the updated `target`
+     * element for the hovercard.
+     */
+    override updated(changedProperties: PropertyValues) {
+      super.updated(changedProperties);
+      if (changedProperties.has('for')) {
+        this.removeTargetEventListeners();
+        this._target = this.target;
+        this.addTargetEventListeners();
+      }
+    }
+
+    readonly debounceHide = () => {
+      this.cancelShowTask();
+      if (!this._isShowing || this.isScheduledToHide) return;
+      this.isScheduledToHide = true;
+      this.hideTask = debounce(
+        this.hideTask,
+        () => {
+          // This happens when hide immediately through click or mouse leave
+          // on the hovercard
+          if (!this.isScheduledToHide) return;
+          this.hide();
+        },
+        HIDE_DELAY_MS
+      );
+    };
+
+    cancelHideTask() {
+      if (!this.hideTask) return;
+      this.hideTask.cancel();
+      this.isScheduledToHide = false;
+      this.hideTask = undefined;
+    }
+
+    /**
+     * Hovercard elements are created outside of <gr-app>, so if you want to fire
+     * events, then you probably want to do that through the target element.
+     */
+
+    dispatchEventThroughTarget(eventName: string): void;
+
+    dispatchEventThroughTarget(
+      eventName: 'show-alert',
+      detail: ShowAlertEventDetail
+    ): void;
+
+    dispatchEventThroughTarget(
+      eventName: 'reload',
+      detail: ReloadEventDetail
+    ): void;
+
+    dispatchEventThroughTarget(eventName: string, detail?: unknown) {
+      if (!detail) detail = {};
+      if (this._target)
+        this._target.dispatchEvent(
+          new CustomEvent(eventName, {
+            detail,
+            bubbles: true,
+            composed: true,
+          })
+        );
+    }
+
+    /**
+     * Returns the target element that the hovercard is anchored to (the `id` of
+     * the `for` property).
+     */
+    get target(): HTMLElement {
+      const parentNode = this.parentNode;
+      // If the parentNode is a document fragment, then we need to use the host.
+      const ownerRoot = this.getRootNode() as ShadowRoot;
+      let target;
+      if (this.for) {
+        target = ownerRoot.querySelector('#' + this.for);
+      } else {
+        target =
+          !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
+            ? ownerRoot.host
+            : parentNode;
+      }
+      return target as HTMLElement;
+    }
+
+    /**
+     * Hides/closes the hovercard. This occurs when the user triggers the
+     * `mouseleave` event on the hovercard's `target` element (as long as the
+     * user is not hovering over the hovercard).
+     *
+     */
+    readonly hide = (e?: MouseEvent) => {
+      this.cancelHideTask();
+      this.cancelShowTask();
+      if (!this._isShowing) {
+        return;
+      }
+
+      // If the user is now hovering over the hovercard or the user is returning
+      // from the hovercard but now hovering over the target (to stop an annoying
+      // flicker effect), just return.
+      if (e) {
+        if (
+          e.relatedTarget === this ||
+          (e.target === this && e.relatedTarget === this._target)
+        ) {
+          return;
+        }
+      }
+
+      // Mark that the hovercard is not visible and do not allow focusing
+      this._isShowing = false;
+
+      // Clear styles in preparation for the next time we need to show the card
+      this.classList.remove(HOVER_CLASS);
+
+      // Reset and remove the hovercard from the DOM
+      this.style.cssText = '';
+      this.topElement?.setAttribute('tabindex', '-1');
+
+      // Remove the hovercard from the container, given that it is still a child
+      // of the container.
+      if (this.container?.contains(this)) {
+        this.container.removeChild(this);
+      }
+    };
+
+    /**
+     * Shows/opens the hovercard with a fixed delay.
+     */
+    readonly debounceShow = () => {
+      this.debounceShowBy(SHOW_DELAY_MS);
+    };
+
+    /**
+     * Shows/opens the hovercard with the given delay.
+     */
+    debounceShowBy(delayMs: number) {
+      this.cancelHideTask();
+      if (this._isShowing || this.isScheduledToShow) return;
+      this.isScheduledToShow = true;
+      this.showTask = debounce(
+        this.showTask,
+        () => {
+          // This happens when the mouse leaves the target before the delay is over.
+          if (!this.isScheduledToShow) return;
+          this.show();
+        },
+        delayMs
+      );
+    }
+
+    cancelShowTask() {
+      if (!this.showTask) return;
+      this.showTask.cancel();
+      this.isScheduledToShow = false;
+      this.showTask = undefined;
+    }
+
+    /**
+     * Shows/opens the hovercard. This occurs when the user triggers the
+     * `mousenter` event on the hovercard's `target` element.
+     */
+    readonly show = async () => {
+      this.cancelHideTask();
+      this.cancelShowTask();
+      if (this._isShowing || !this.container) {
+        return;
+      }
+
+      // Mark that the hovercard is now visible
+      this._isShowing = true;
+      this.setAttribute('tabindex', '0');
+
+      // Add it to the DOM and calculate its position
+      this.container.appendChild(this);
+      // We temporarily hide the hovercard until we have found the correct
+      // position for it.
+      this.classList.add(HIDE_CLASS);
+      this.classList.add(HOVER_CLASS);
+      // Make sure that the hovercard actually rendered and all dom-if
+      // statements processed, so that we can measure the (invisible)
+      // hovercard properly in updatePosition().
+      await new Promise<void>(r => {
+        setTimeout(r, 0);
+      });
+      this.updatePosition();
+      this.classList.remove(HIDE_CLASS);
+    };
+
+    updatePosition() {
+      const positionsToTry = new Set([
+        this.position,
+        'right',
+        'bottom-right',
+        'top-right',
+        'bottom',
+        'top',
+        'bottom-left',
+        'top-left',
+        'left',
+      ]);
+      for (const position of positionsToTry) {
+        this.updatePositionTo(position);
+        if (this._isInsideViewport()) return;
+      }
+      console.warn('Could not find a visible position for the hovercard.');
+    }
+
+    _isInsideViewport() {
+      const thisRect = this.getBoundingClientRect();
+      if (thisRect.top < 0) return false;
+      if (thisRect.left < 0) return false;
+      const docuRect = document.documentElement.getBoundingClientRect();
+      if (thisRect.bottom > docuRect.height) return false;
+      if (thisRect.right > docuRect.width) return false;
+      return true;
+    }
+
+    /**
+     * Updates the hovercard's position based the current position of the `target`
+     * element.
+     *
+     * The hovercard is supposed to stay open if the user hovers over it.
+     * To keep it open when the user moves away from the target, the bounding
+     * rects of the target and hovercard must touch or overlap.
+     *
+     * NOTE: You do not need to directly call this method unless you need to
+     * update the position of the tooltip while it is already visible (the
+     * target element has moved and the tooltip is still open).
+     */
+    updatePositionTo(position: string) {
+      if (!this._target) {
+        return;
+      }
+
+      // Make sure that thisRect will not get any paddings and such included
+      // in the width and height of the bounding client rect.
+      this.style.cssText = '';
+
+      const docuRect = document.documentElement.getBoundingClientRect();
+      const targetRect = this._target.getBoundingClientRect();
+      const thisRect = this.getBoundingClientRect();
+
+      const targetLeft = targetRect.left - docuRect.left;
+      const targetTop = targetRect.top - docuRect.top;
+
+      let hovercardLeft;
+      let hovercardTop;
+
+      switch (position) {
+        case 'top':
+          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+          hovercardTop = targetTop - thisRect.height - this.offset;
+          break;
+        case 'bottom':
+          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+          hovercardTop = targetTop + targetRect.height + this.offset;
+          break;
+        case 'left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+          break;
+        case 'right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+          break;
+        case 'bottom-right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop;
+          break;
+        case 'bottom-left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop;
+          break;
+        case 'top-left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop + targetRect.height - thisRect.height;
+          break;
+        case 'top-right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop + targetRect.height - thisRect.height;
+          break;
+      }
+
+      this.style.left = `${hovercardLeft}px`;
+      this.style.top = `${hovercardTop}px`;
+    }
+  }
+
+  return Mixin as T & Constructor<HovercardMixinInterface>;
+};
+
+export interface HovercardMixinInterface {
+  for?: string;
+  offset: number;
+  _target: HTMLElement | null;
+  _isShowing: boolean;
+  dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
+  show(): void;
+
+  // Used for tests
+  hide(e: MouseEvent): void;
+  container: HTMLElement | null;
+  hideTask?: DelayedTask;
+  showTask?: DelayedTask;
+  position: string;
+  debounceShowBy(delayMs: number): void;
+  updatePosition(): void;
+  isScheduledToShow?: boolean;
+  isScheduledToHide?: boolean;
+}
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
new file mode 100644
index 0000000..bd12789
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {HovercardMixin} from './hovercard-mixin.js';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
+import {MockPromise, mockPromise} from '../../test/test-utils.js';
+
+const base = HovercardMixin(LitElement);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'hovercard-mixin-test': HovercardMixinTest;
+  }
+}
+
+@customElement('hovercard-mixin-test')
+class HovercardMixinTest extends base {
+  constructor() {
+    super();
+    this.for = 'foo';
+  }
+
+  override render() {
+    return html`<div id="container"><slot></slot></div>`;
+  }
+}
+
+const basicFixture = fixtureFromElement('hovercard-mixin-test');
+
+suite('gr-hovercard tests', () => {
+  let element: HovercardMixinTest;
+
+  let button: HTMLElement;
+  let testPromise: MockPromise;
+
+  setup(() => {
+    testPromise = mockPromise();
+    button = document.createElement('button');
+    button.innerHTML = 'Hello';
+    button.setAttribute('id', 'foo');
+    document.body.appendChild(button);
+
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    element.hide(new MouseEvent('click'));
+    button?.remove();
+  });
+
+  test('updatePosition', async () => {
+    // Test that the correct style properties have at least been set.
+    element.position = 'bottom';
+    element.updatePosition();
+    await element.updateComplete;
+    assert.typeOf(element.style.getPropertyValue('left'), 'string');
+    assert.typeOf(element.style.getPropertyValue('top'), 'string');
+    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = element!._target!.getBoundingClientRect();
+    const thisRect = element.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    const pixelCompare = (pixel: string) =>
+      Math.round(parseInt(pixel.substring(0, pixel.length - 1), 10));
+
+    assert.equal(
+      pixelCompare(element.style.left),
+      pixelCompare(`${targetLeft + (targetRect.width - thisRect.width) / 2}px`)
+    );
+    assert.equal(
+      pixelCompare(element.style.top),
+      pixelCompare(`${targetTop + targetRect.height + element.offset}px`)
+    );
+  });
+
+  test('hide', () => {
+    element.hide(new MouseEvent('click'));
+    const style = getComputedStyle(element);
+    assert.isFalse(element._isShowing);
+    assert.isFalse(element.classList.contains('hovered'));
+    assert.equal(style.display, 'none');
+    assert.notEqual(element.container, element.parentNode);
+  });
+
+  test('show', async () => {
+    await element.show();
+    await element.updateComplete;
+    const style = getComputedStyle(element);
+    assert.isTrue(element._isShowing);
+    assert.isTrue(element.classList.contains('hovered'));
+    assert.equal(style.opacity, '1');
+    assert.equal(style.visibility, 'visible');
+  });
+
+  test('debounceShow does not show immediately', async () => {
+    element.debounceShowBy(100);
+    setTimeout(() => testPromise.resolve(), 0);
+    await testPromise;
+    assert.isFalse(element._isShowing);
+  });
+
+  test('debounceShow shows after delay', async () => {
+    element.debounceShowBy(1);
+    setTimeout(() => testPromise.resolve(), 10);
+    await testPromise;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('card is scheduled to show on enter and hides on leave', async () => {
+    const button = document.querySelector('button');
+    const enterPromise = mockPromise();
+    button!.addEventListener('mouseenter', () => enterPromise.resolve());
+    const leavePromise = mockPromise();
+    button!.addEventListener('mouseleave', () => leavePromise.resolve());
+
+    assert.isFalse(element._isShowing);
+    button!.dispatchEvent(new CustomEvent('mouseenter'));
+
+    await enterPromise;
+    await flush();
+    assert.isTrue(element.isScheduledToShow);
+    element!.showTask!.flush();
+    assert.isTrue(element._isShowing);
+    assert.isFalse(element.isScheduledToShow);
+
+    button!.dispatchEvent(new CustomEvent('mouseleave'));
+
+    await leavePromise;
+    assert.isTrue(element.isScheduledToHide);
+    assert.isTrue(element._isShowing);
+    element!.hideTask!.flush();
+    assert.isFalse(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+
+  test('card should disappear on click', async () => {
+    const button = document.querySelector('button');
+    const enterPromise = mockPromise();
+    const clickPromise = mockPromise();
+    button!.addEventListener('mouseenter', () => enterPromise.resolve());
+    button!.addEventListener('click', () => clickPromise.resolve());
+
+    assert.isFalse(element._isShowing);
+
+    button!.dispatchEvent(new CustomEvent('mouseenter'));
+
+    await enterPromise;
+    await flush();
+    assert.isTrue(element.isScheduledToShow);
+    button!.click();
+
+    await clickPromise;
+    assert.isFalse(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+});
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 4be6241..2ad4e79 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -38,7 +38,7 @@
     "ba-linkify": "^1.0.1",
     "codemirror-minified": "^5.62.2",
     "immer": "^9.0.5",
-    "lit": "2.0.0-rc.3",
+    "lit": "2.0.2",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
new file mode 100644
index 0000000..f214a9c
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {css} from 'lit';
+
+export const hovercardStyles = css`
+  :host {
+    position: absolute;
+    display: none;
+    z-index: 200;
+    max-width: 600px;
+    outline: none;
+  }
+  :host(.hovered) {
+    display: block;
+  }
+  :host(.hide) {
+    visibility: hidden;
+  }
+  /* You have to use a <div class="container"> in your hovercard in order
+      to pick up this consistent styling. */
+  #container {
+    background: var(--dialog-background-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    box-shadow: var(--elevation-level-5);
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-hovercard-styles">
+  <template>
+    <style>
+    ${hovercardStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 395ef64..bfca566 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,10 +2,10 @@
 # yarn lockfile v1
 
 
-"@lit/reactive-element@^1.0.0-rc.2":
-  version "1.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.0-rc.3.tgz#5032f493fbf39781b187a7e2dd5d256537c8760c"
-  integrity sha512-Rs2px1keOQUNJUo5B+WExl5v244ZNCiN/iMVNO9evFdJjAdWCIupR/p14zRPkNHsciRBELLTcOZ379cI9O6PDg==
+"@lit/reactive-element@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
+  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.5"
@@ -425,10 +425,10 @@
   resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302"
   integrity sha512-61IfTac0s9jvNtBCpyo86QeaN8qqpMGHdK0uGKCCIy2dt5/Yk84VduHIdWAcmkC5QvdkPL0p5eWYgUZtHKKUVg==
 
-"@types/trusted-types@^1.0.1":
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da"
-  integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 "@webcomponents/shadycss@^1.10.2", "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
@@ -473,9 +473,9 @@
   integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
 
 are-we-there-yet@~1.1.2:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
-  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+  version "1.1.7"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146"
+  integrity sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==
   dependencies:
     delegates "^1.0.0"
     readable-stream "^2.0.6"
@@ -518,9 +518,9 @@
   integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
 
 codemirror-minified@^5.62.2:
-  version "5.62.2"
-  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.62.2.tgz#37d866f5f39bbd4482c60b1607c669bcb7190388"
-  integrity sha512-lQpyiEaqyEln1YDiHqq8lJcX8GkTJamecZAn0DkgdteFIVCRHnVmllOXPF+d159OSNkMi1UcKRObcU6ueBHe1A==
+  version "5.63.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.63.0.tgz#29d1a78713a633c933a27853679afdc0bfea49cc"
+  integrity sha512-dMN2w0Qg5Zwn2p7UW3sYAoyrJ+QRBkiF5bfbQAvQ1bfqhEjGnZ++/zvOG7NivfnUbYRhSULz8lsFtzt4ldBNyQ==
 
 concat-map@0.0.1:
   version "0.0.1"
@@ -533,9 +533,9 @@
   integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
 
 core-util-is@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
+  integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
 
 debug@4:
   version "4.3.2"
@@ -588,9 +588,9 @@
     wide-align "^1.1.0"
 
 glob@^7.1.3:
-  version "7.1.7"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
-  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -613,9 +613,9 @@
     debug "4"
 
 immer@^9.0.5:
-  version "9.0.5"
-  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.5.tgz#a7154f34fe7064f15f00554cc94c66cc0bf453ec"
-  integrity sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ==
+  version "9.0.6"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73"
+  integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==
 
 inflight@^1.0.4:
   version "1.0.6"
@@ -652,29 +652,29 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-lit-element@^3.0.0-rc.2:
-  version "3.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.0-rc.3.tgz#cece8f092d28eb6f9c6b23e4138ff5d7260897ef"
-  integrity sha512-NDe7yjW18gfYQb1GIEQr1T8sB1GUAb1HB62pdAEw+SK6lUW7OFPKQqCOlRhZ6qJXsw9KxMnyYIprLZT4FZdYdQ==
+lit-element@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
+  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-html "^2.0.0-rc.4"
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
 
-lit-html@^2.0.0-rc.4:
-  version "2.0.0-rc.4"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.0-rc.4.tgz#1015fa8f1f7c8c5b79999ed0bc11c3b79ff1aab5"
-  integrity sha512-WSLGu3vxq7y8q/oOd9I3zxyBELNLLiDk6gAYoKK4PGctI5fbh6lhnO/jVBdy0PV/vTc+cLJCA/occzx3YoNPeg==
+lit-html@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
+  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
+    "@types/trusted-types" "^2.0.2"
 
-lit@2.0.0-rc.3:
-  version "2.0.0-rc.3"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.0-rc.3.tgz#8b6a85268aba287c11125dfe57e88e0bc09beaff"
-  integrity sha512-UZDLWuspl7saA+WvS0e+TE3NdGGE05hOIwUPTWiibs34c5QupcEzpjB/aElt79V9bELQVNbUUwa0Ow7D1Wuszw==
+lit@2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-element "^3.0.0-rc.2"
-    lit-html "^2.0.0-rc.4"
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
@@ -703,9 +703,9 @@
     brace-expansion "^1.1.7"
 
 minipass@^3.0.0:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
-  integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.5.tgz#71f6251b0a33a49c01b3cf97ff77eda030dff732"
+  integrity sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==
   dependencies:
     yallist "^4.0.0"
 
@@ -733,9 +733,11 @@
   integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
 
 node-fetch@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
+  version "2.6.5"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd"
+  integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ==
+  dependencies:
+    whatwg-url "^5.0.0"
 
 nopt@^5.0.0:
   version "5.0.0"
@@ -863,9 +865,9 @@
   integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
 
 signal-exit@^3.0.0:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
-  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
+  integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
 
 simple-concat@^1.0.0:
   version "1.0.1"
@@ -920,9 +922,9 @@
     ansi-regex "^3.0.0"
 
 tar@^6.1.0:
-  version "6.1.8"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.8.tgz#4fc50cfe56511c538ce15b71e05eebe66530cbd4"
-  integrity sha512-sb9b0cp855NbkMJcskdSYA7b11Q8JsX4qe4pyUAfHp+Y6jBjJeek2ZVlwEfWayshEIwlIzXx0Fain3QG9JPm2A==
+  version "6.1.11"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
+  integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
   dependencies:
     chownr "^2.0.0"
     fs-minipass "^2.0.0"
@@ -931,6 +933,11 @@
     mkdirp "^1.0.3"
     yallist "^4.0.0"
 
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
+
 tslib@^1.9.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@@ -941,6 +948,19 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
 wide-align@^1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"