Merge "GitVisibleChangeFilter: Remove unnecessary nested else clause"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 9df4b04..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -456,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..1587bc5 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,7 +122,12 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
   @Override
@@ -83,13 +140,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +171,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +188,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +246,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +258,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +277,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 5cd0e98..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -328,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..43153ae
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+}
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index c5c262b..732a82c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -240,34 +240,6 @@
 the "Before launch" section for IntelliJ. This is a temporary problem until
 typescript migration is complete.
 
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
 ### Generated file overview
 
 A generated file starts with imports followed by a static content with
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 6788aa3..4322c64 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -38,10 +38,16 @@
  * If the weblinks-only parameter is specified, only the web_links field is set.
  */
 export declare interface DiffInfo {
-  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
-  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -167,7 +173,7 @@
    * Indicates the range (line numbers) on the other side of the comparison
    * where the code related to the current chunk came from/went to.
    */
-  range: {
+  range?: {
     start: number;
     end: number;
   };
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 933b300..b4ebd3c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -40,12 +40,9 @@
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
@@ -248,22 +245,6 @@
         .subject:hover .content {
           text-decoration: underline;
         }
-        .u-monospace {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        .u-green,
-        .u-green iron-icon {
-          color: var(--positive-green-text-color);
-        }
-        .u-red,
-        .u-red iron-icon {
-          color: var(--negative-red-text-color);
-        }
-        .u-gray-background {
-          background-color: var(--table-header-background-color);
-        }
         .comma,
         .placeholder {
           color: var(--deemphasized-text-color);
@@ -618,32 +599,13 @@
   }
 
   private renderChangeLabels(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return html` <td class="cell label requirement">
-        <gr-change-list-column-requirement
-          .change=${this.change}
-          .labelName=${labelName}
-        >
-        </gr-change-list-column-requirement>
-      </td>`;
-    }
-    return html`
-      <td
-        title=${this.computeLabelTitle(labelName)}
-        class=${this.computeLabelClass(labelName)}
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
       >
-        ${this.renderChangeHasLabelIcon(labelName)}
-      </td>
-    `;
-  }
-
-  private renderChangeHasLabelIcon(labelName: string) {
-    if (this.computeLabelIcon(labelName) === '')
-      return html`<span>${this.computeLabelValue(labelName)}</span>`;
-
-    return html`
-      <iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
-    `;
+      </gr-change-list-column-requirement>
+    </td>`;
   }
 
   private renderChangePluginEndpoint(pluginEndpointName: string) {
@@ -676,118 +638,6 @@
     return GerritNav.getUrlForChange(this.change);
   }
 
-  // private but used in test
-  computeLabelTitle(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    if (!label || category === LabelCategory.NOT_APPLICABLE) {
-      return 'Label not applicable';
-    }
-    const titleParts: string[] = [];
-    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
-      const num = this.change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
-
-  // private but used in test
-  computeLabelClass(labelName: string) {
-    const classes = ['cell', 'label'];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  // private but used in test
-  computeLabelIcon(labelName: string): string {
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
-    }
-  }
-
-  // private but used in test
-  computeLabelCategory(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
-    }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
-  }
-
-  // private but used in test
-  computeLabelValue(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-      default:
-        return '';
-    }
-  }
-
   private computeRepoUrl() {
     if (!this.change) return '';
     return GerritNav.getUrlForProjectChanges(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index e0ce6a8..7216249 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -48,7 +48,7 @@
 import {StandardLabels} from '../../../utils/label-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -92,209 +92,6 @@
     await element.updateComplete;
   });
 
-  test('computeLabelCategory', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.APPROVED
-    );
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.REJECTED
-    );
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.UNRESOLVED_COMMENTS
-    );
-    element.change.labels = {'Code-Review': {value: 1}};
-    element.change.unresolved_comment_count = 0;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.POSITIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.NEGATIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-  });
-
-  test('computeLabelClass', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-green');
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-red');
-    element.change.labels = {'Code-Review': {value: 1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-green u-monospace'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-monospace u-red'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-  });
-
-  test('computeLabelTitle', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelTitle('Verified'), 'Label not applicable');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Label not applicable'
-    );
-
-    element.change.labels = {Verified: {rejected: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {
-      'Code-Review': {disliked: {name: 'Diffy'}, value: -1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        recommended: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        approved: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, value: 1},
-    };
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment,\nCode-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 2;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '2 unresolved comments'
-    );
-  });
-
-  test('computeLabelIcon', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelIcon('missingLabel'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelIcon('Verified'), 'gr-icons:check');
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(element.computeLabelIcon('Code-Review'), 'gr-icons:comment');
-  });
-
-  test('computeLabelValue', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelValue('Verified'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '+1');
-    element.change.labels = {Verified: {value: -1}};
-    assert.equal(element.computeLabelValue('Verified'), '-1');
-    element.change.labels = {Verified: {approved: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {rejected: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✕');
-  });
-
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
       ColumnNames.SUBJECT,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 24719e8..34080a1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -10,7 +10,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {
-  AccountInfo,
+  AccountDetailInfo,
   ChangeInfo,
   NumericChangeId,
   ServerInfo,
@@ -30,8 +30,14 @@
 import {allSettled} from '../../../utils/async-util';
 import {listForSentence} from '../../../utils/string-util';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
+import {
+  AccountInput,
+  AccountInputDetail,
+} from '../../shared/gr-account-list/gr-account-list';
 import '@polymer/iron-icon/iron-icon';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection} from '../../../utils/common-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
@@ -40,7 +46,7 @@
   // contents are given to gr-account-lists to mutate
   @state() private updatedAccountsByReviewerState: Map<
     ReviewerState,
-    AccountInfo[]
+    AccountInput[]
   > = new Map([
     [ReviewerState.REVIEWER, []],
     [ReviewerState.CC, []],
@@ -72,6 +78,8 @@
 
   private isLoggedIn = false;
 
+  private account?: AccountDetailInfo;
+
   static override get styles() {
     return css`
       gr-dialog {
@@ -125,6 +133,11 @@
       () => getAppContext().userModel.loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
+    subscribe(
+      this,
+      () => getAppContext().userModel.account$,
+      account => (this.account = account)
+    );
   }
 
   override render() {
@@ -244,12 +257,11 @@
       .filter(account => account?._account_id !== undefined);
     return this.updatedAccountsByReviewerState
       .get(updatedReviewerState)!
-      .filter(
-        account =>
-          account._account_id !== undefined &&
-          accountsInCurrentState.some(
-            otherAccount => otherAccount._account_id === account._account_id
-          )
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount =>
+            accountOrGroupKey(otherAccount) === accountOrGroupKey(account)
+        )
       )
       .map(reviewer => getDisplayName(this.serverConfig, reviewer));
   }
@@ -292,7 +304,6 @@
     reviewerState: ReviewerState,
     event: CustomEvent<AccountInputDetail>
   ) {
-    const account = event.detail.account as AccountInfo;
     const oppositeReviewerState =
       reviewerState === ReviewerState.CC
         ? ReviewerState.REVIEWER
@@ -301,7 +312,7 @@
       oppositeReviewerState
     )!;
     const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
-      acc => acc._account_id === account._account_id
+      acc => accountOrGroupKey(acc) === accountOrGroupKey(event.detail.account)
     );
     if (oppositeUpdatedAccountIndex >= 0) {
       oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
@@ -335,7 +346,8 @@
       ])
     );
     const inFlightActions = this.getBulkActionsModel().addReviewers(
-      this.updatedAccountsByReviewerState
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
     );
 
     await allSettled(
@@ -383,13 +395,7 @@
     const reviewersPerChange = this.selectedChanges.map(
       change => change.reviewers[reviewerState] ?? []
     );
-    if (reviewersPerChange.length === 0) {
-      return [];
-    }
-    // Gets reviewers present in all changes
-    return reviewersPerChange.reduce((a, b) =>
-      a.filter(reviewer => b.includes(reviewer))
-    );
+    return intersection(reviewersPerChange);
   }
 
   private createSuggestionsProvider(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index edcad8f..4a142e4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -5,7 +5,7 @@
  */
 import {fixture, html} from '@open-wc/testing-helpers';
 import {SinonStubbedMember} from 'sinon';
-import {AccountInfo, ReviewerState} from '../../../api/rest-api';
+import {AccountInfo, GroupInfo, ReviewerState} from '../../../api/rest-api';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
@@ -17,6 +17,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
 } from '../../../test/test-data-generators';
 import {
   MockPromise,
@@ -43,6 +44,7 @@
   createAccountWithIdNameAndEmail(4),
   createAccountWithIdNameAndEmail(5),
 ];
+const groups: GroupInfo[] = [createGroupInfo('groupId')];
 const changes: ChangeInfo[] = [
   {
     ...createChange(),
@@ -225,7 +227,7 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      reviewerList.accounts.push(accounts[2]);
+      reviewerList.accounts.push(accounts[2], groups[0]);
       ccList.accounts.push(accounts[5]);
       await flush();
       dialog.confirmButton!.click();
@@ -243,8 +245,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
@@ -253,8 +268,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
@@ -357,7 +385,7 @@
       );
       await flush();
 
-      // prettier and shadoDom string don't agree on long text in divs
+      // prettier and shadowDom string don't agree on long text in divs
       expect(element).shadowDom.to.equal(
         /* prettier-ignore */
         /* HTML */ `
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 2a90bfc..2f5965b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -45,7 +45,6 @@
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {queryAll} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
 import {Execution} from '../../../constants/reporting';
 
@@ -309,11 +308,7 @@
         const prefColumns = this.preferences.change_table
           .map(column => (column === 'Project' ? ColumnNames.REPO : column))
           .map(column =>
-            this.flagsService.isEnabled(
-              KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-            ) && column === ColumnNames.STATUS
-              ? ColumnNames.STATUS2
-              : column
+            column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column
           );
         this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, {
           statusColumn: prefColumns.includes(ColumnNames.STATUS2),
@@ -336,50 +331,23 @@
     if (!config || !config.change) return true;
     if (column === 'Comments')
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status') {
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    }
-    if (column === ColumnNames.STATUS2)
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      if (this.config?.submit_requirement_dashboard_columns?.length) {
-        return this.config?.submit_requirement_dashboard_columns;
-      } else {
-        const changes = sections.map(section => section.results).flat();
-        labels = (changes ?? [])
-          .map(change => getRequirements(change))
-          .flat()
-          .map(requirement => requirement.name)
-          .filter(unique);
-      }
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 63a6d8f..1e81123 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -32,6 +32,7 @@
 import {
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
@@ -165,23 +166,36 @@
             {
               ...createChange(),
               _number: 0 as NumericChangeId,
-              labels: {Verified: {approved: {}}},
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 1 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Code-Review': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 2 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Library-Compliance': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
             },
           ],
         },
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 95ad376..9df22f1 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
@@ -29,7 +29,6 @@
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
@@ -83,11 +82,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -179,8 +174,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
@@ -195,7 +188,6 @@
       :host {
         display: table;
       }
-      gr-change-requirements,
       gr-submit-requirements {
         --requirements-horizontal-padding: var(--metadata-horizontal-padding);
       }
@@ -702,23 +694,13 @@
   }
 
   private renderSubmitRequirements() {
-    if (this.showNewSubmitRequirements()) {
-      return html`<div class="separatedSection">
-        <gr-submit-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-submit-requirements>
-      </div>`;
-    } else {
-      return html` <div class="oldSeparatedSection">
-        <gr-change-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-change-requirements>
-      </div>`;
-    }
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
   }
 
   private renderWeblinks() {
@@ -1213,10 +1195,6 @@
       );
   }
 
-  private showNewSubmitRequirements() {
-    return showNewSubmitRequirements(this.flagsService, this.change);
-  }
-
   private computeVoteForRole(role: ChangeRole) {
     const reviewer = this.getNonOwnerRole(role);
     if (reviewer && isAccount(reviewer)) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 2b48697..a383cb1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -199,8 +199,8 @@
         <span class="title"> Hashtags </span>
         <span class="value"> </span>
       </section>
-      <div class="oldSeparatedSection">
-      <gr-change-requirements></gr-change-requirements>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
       </div>
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param name="labels"> </gr-endpoint-param>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 821e1ce..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +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 '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {getAppContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels).sort(labelCompare)) {
-      allLabels.push({
-        labelName: label,
-        icon: this._computeLabelIcon(labels[label]),
-        style: this._computeLabelClass(labels[label]),
-        labelInfo: labels[label],
-      });
-    }
-    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
-    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: 'optional labels',
-      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
-    });
-  }
-
-  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
-    return `submit-requirement-item-${item.type}`;
-  }
-
-  _computeShowAllLabelText(_showOptionalLabels: boolean) {
-    if (_showOptionalLabels) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
deleted file mode 100644
index 0005c90..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .show-all-button {
-      float: right;
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-    gr-endpoint-param {
-      display: none;
-    }
-    .metadata-title {
-      font-weight: var(--font-weight-bold);
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .title .metadata-title {
-      padding-left: 0;
-    }
-  </style>
-  <h3 class="metadata-title heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +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-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 105c0d8..3db1012 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -633,6 +633,12 @@
 
   private connected$ = new BehaviorSubject(false);
 
+  /**
+   * For `connectedCallback()` to distinguish between connecting to the DOM for
+   * the first time or if just re-connecting.
+   */
+  private isFirstConnection = true;
+
   /** Simply reflects the router-model value. */
   // visible for testing
   routerPatchNum?: PatchSetNum;
@@ -649,12 +655,29 @@
       'fullscreen-overlay-opened',
       () => this._handleHideBackgroundContent()
     );
-
     this.addEventListener('fullscreen-overlay-closed', () =>
       this._handleShowBackgroundContent()
     );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      this.loadData(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
   }
 
   private setupSubscriptions() {
@@ -699,24 +722,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    this.firstConnectedCallback();
     this.connected$.next(true);
-    this.setupSubscriptions();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
+    // Make sure to reverse everything below this line in disconnectedCallback().
+    // Or consider using either firstConnectedCallback() or constructor().
+    this.setupSubscriptions();
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
+  }
+
+  /**
+   * For initialization that should only happen once, not again when
+   * re-connecting to the DOM later.
+   */
+  private firstConnectedCallback() {
+    if (!this.isFirstConnection) return;
+    this.isFirstConnection = false;
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -734,26 +756,21 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('change-message-deleted', () => fireReload(this));
-    this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
-    this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
-    );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    document.addEventListener('visibilitychange', this.handleVisibilityChange);
-    document.addEventListener('scroll', this.handleScroll);
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
 
-    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
-      this._setActivePrimaryTab(e)
-    );
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.restApiService.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 8d82e41..66df866 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -17,11 +17,9 @@
 import '../../../test/common-test-setup-karma';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import './gr-file-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {runA11yAudit} from '../../../test/a11y-test-utils';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   listenOnce,
   mockPromise,
@@ -61,12 +59,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrEditFileControls} from '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 
-const commentApiMock = createCommentApiMockWithTemplateElement(
-  'gr-file-list-comment-api-mock',
-  html` <gr-file-list id="fileList"></gr-file-list> `
-);
-
-const basicFixture = fixtureFromElement(commentApiMock.is);
+const basicFixture = fixtureFromElement('gr-file-list');
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -85,7 +78,6 @@
 
 suite('gr-file-list tests', () => {
   let element: GrFileList;
-  let commentApiWrapper: any;
 
   let saveStub: sinon.SinonStub;
 
@@ -103,8 +95,7 @@
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
+      element = basicFixture.instantiate();
 
       element._loading = false;
       element.diffPrefs = {} as DiffPreferencesInfo;
@@ -1976,8 +1967,7 @@
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
+      element = basicFixture.instantiate();
       element.diffPrefs = {} as DiffPreferencesInfo;
       element.change = {
         ...createParsedChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 2988bc6..6bd3f62 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -27,9 +27,6 @@
   DetailedLabelInfo,
 } from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {classMap} from 'lit/directives/class-map';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
 
@@ -68,12 +65,6 @@
   @state()
   private selectedValueText = 'No value selected';
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   static override get styles() {
     return [
       sharedStyles,
@@ -87,8 +78,6 @@
         /* We want the :hover highlight to extend to the border of the dialog. */
         .labelNameCell {
           padding-left: var(--label-score-padding-left, 0);
-        }
-        .labelNameCell.newSubmitRequirements {
           width: 160px;
         }
         .selectedValueCell {
@@ -100,9 +89,6 @@
           white-space: nowrap;
         }
         .selectedValueCell {
-          width: 75%;
-        }
-        .selectedValueCell.newSubmitRequirements {
           width: 52%;
         }
         .labelMessage {
@@ -175,13 +161,7 @@
 
   override render() {
     return html`
-      <span
-        class=${classMap({
-          labelNameCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-        id="labelName"
-        aria-hidden="true"
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
         >${this.label?.name ?? ''}</span
       >
       ${this.renderButtonsCell()} ${this.renderSelectedValue()}
@@ -257,12 +237,7 @@
 
   private renderSelectedValue() {
     return html`
-      <div
-        class=${classMap({
-          selectedValueCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-      >
+      <div class="selectedValueCell">
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 27c445e..8e757da 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -24,10 +24,8 @@
   LabelNameToValueMap,
 } from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
-import {getAppContext} from '../../../services/app-context';
 import {
   getTriggerVotes,
-  showNewSubmitRequirements,
   computeLabels,
   Label,
   computeOrderedLabelValues,
@@ -48,8 +46,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -57,8 +53,6 @@
         .scoresTable {
           display: table;
           width: 100%;
-        }
-        .scoresTable.newSubmitRequirements {
           table-layout: fixed;
         }
         .mergedMessage,
@@ -91,19 +85,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = computeLabels(this.account, this.change);
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
@@ -145,13 +126,7 @@
   }
 
   private renderLabels(labels: Label[]) {
-    const newSubReqs = showNewSubmitRequirements(
-      this.flagsService,
-      this.change
-    );
-    return html`<div
-      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
-    >
+    return html`<div class="scoresTable">
       ${labels
         .filter(
           label =>
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 4bf9d10..f204d76 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -25,8 +25,6 @@
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -102,8 +100,6 @@
     `;
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
@@ -112,7 +108,6 @@
 
   private renderScore(score: Score, triggerVotes: string[]) {
     if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
       score.label &&
       triggerVotes.includes(score.label) &&
       !score.value?.includes(VOTE_RESET_TEXT)
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index b9cb616..ac487f8 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -17,10 +17,8 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-messages-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
 import {MessageTag} from '../../../constants/constants';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   query,
   queryAll,
@@ -43,16 +41,7 @@
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {assertIsDefined} from '../../../utils/common-util';
 
-createCommentApiMockWithTemplateElement(
-  'gr-messages-list-comment-mock-api',
-  html` <gr-messages-list id="messagesList"></gr-messages-list> `
-);
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-messages-list-comment-mock-api>
-    <gr-messages-list></gr-messages-list>
-  </gr-messages-list-comment-mock-api>
-`);
+const basicFixture = fixtureFromElement('gr-messages-list');
 
 const author = {
   _account_id: 42 as AccountId,
@@ -99,8 +88,6 @@
   let element: GrMessagesList;
   let messages: ChangeMessageInfo[];
 
-  let commentApiWrapper: any;
-
   const getMessages = function () {
     return queryAll<GrMessage>(element, 'gr-message');
   };
@@ -156,13 +143,7 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
-      );
+      element = basicFixture.instantiate();
       await element.getCommentsModel().reloadComments(0 as NumericChangeId);
       element.messages = messages;
       await flush();
@@ -507,8 +488,6 @@
     let element: GrMessagesList;
     let messages: ChangeMessageInfo[];
 
-    let commentApiWrapper: any;
-
     setup(() => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -529,13 +508,7 @@
         }),
       ];
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
-      );
+      element = basicFixture.instantiate();
       element.messages = messages;
       flush();
     });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 1f57837..6a9bd92 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -261,7 +261,7 @@
   account?: AccountInfo;
 
   @state()
-  ccs: (AccountInfoInput | GroupInfoInput)[] = [];
+  ccs: AccountInput[] = [];
 
   @state()
   attentionCcsCount = 0;
@@ -1527,8 +1527,8 @@
     if (!this.change?.owner || !this.change?.reviewers) return;
     this.owner = this.change.owner;
 
-    const reviewers = [];
-    const ccs = [];
+    const reviewers: AccountInput[] = [];
+    const ccs: AccountInput[] = [];
 
     if (this.change.reviewers) {
       for (const key of Object.keys(this.change.reviewers)) {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 6740977..d5ce654 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -29,12 +29,7 @@
   LabelInfo,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
@@ -68,8 +63,6 @@
 
   @state() showAllReviewers = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       sharedStyles,
@@ -166,14 +159,12 @@
         .vote=${this.computeVote(reviewer)}
         .label=${this.computeCodeReviewLabel()}
       >
-        ${showNewSubmitRequirements(this.flagsService, this.change)
-          ? html`<gr-vote-chip
-              slot="vote-chip"
-              .vote=${this.computeVote(reviewer)}
-              .label=${this.computeCodeReviewLabel()}
-              circle-shape
-            ></gr-vote-chip>`
-          : nothing}
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
       </gr-account-chip>
     `;
   }
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 dda8490..88aae5c 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
@@ -313,7 +313,7 @@
       // Recognizes that it should be an image diff.
       assert.isTrue(element.isImageDiff);
       assert.instanceOf(
-          element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
       // Left image rendered with the parent commit's version of the file.
       const leftImage =
@@ -381,7 +381,7 @@
       // Recognizes that it should be an image diff.
       assert.isTrue(element.isImageDiff);
       assert.instanceOf(
-          element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
       // Left image rendered with the parent commit's version of the file.
       const leftImage =
@@ -445,7 +445,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -493,7 +493,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -543,7 +543,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
         assert.isNotOk(leftImage);
@@ -623,7 +623,7 @@
 
     test('clearBlame', () => {
       element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element.$.diff.diffBuilder, 'setBlame');
       element.clearBlame();
       assert.isNull(element._blame);
       assert.isTrue(setBlameSpy.calledWithExactly(null));
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index dc501c8..2e48771 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -20,7 +20,6 @@
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
-import '../../../test/mocks/comment-api';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {stubRestApi} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 73be9f3..a09cdbc 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -136,14 +135,7 @@
     if (!this.serverConfig?.change) return true;
     if (column === ColumnNames.COMMENTS)
       return this.flagsService.isEnabled('comments-column');
-    if (column === ColumnNames.STATUS)
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    if (column === ColumnNames.STATUS2)
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === ColumnNames.STATUS) return false;
     return true;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index fdea387..42ef8f4 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -71,12 +71,6 @@
             </td>
           </tr>
           <tr>
-            <td><label for="Status"> Status </label></td>
-            <td class="checkboxContainer">
-              <input checked="" id="Status" name="Status" type="checkbox" />
-            </td>
-          </tr>
-          <tr>
             <td><label for="Owner"> Owner </label></td>
             <td class="checkboxContainer">
               <input checked="" id="Owner" name="Owner" type="checkbox" />
@@ -117,6 +111,12 @@
               <input id="Size" name="Size" type="checkbox" />
             </td>
           </tr>
+          <tr>
+            <td><label for=" Status "> Status </label></td>
+            <td class="checkboxContainer">
+              <input id=" Status " name=" Status " type="checkbox" />
+            </td>
+          </tr>
         </tbody>
       </table>
     </div>`);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 81661e2..76259b9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -376,9 +376,7 @@
                 @change=${this.handleToggleDark}
                 @click=${this.onTapDarkToggle}
               ></paper-toggle-button>
-              <div id="darkThemeToggleLabel">
-                Dark theme (the toggle reloads the page)
-              </div>
+              <div id="darkThemeToggleLabel">Dark theme</div>
             </div>
           </section>
           <h2
@@ -1161,6 +1159,7 @@
 
   // private but used in test
   reloadPage() {
+    fireAlert(this, 'Reloading...');
     windowLocationReload();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index a514f00..ae42619 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -177,7 +177,7 @@
               >
               </paper-toggle-button>
               <div id="darkThemeToggleLabel">
-                Dark theme (the toggle reloads the page)
+                Dark theme
               </div>
             </div>
           </section>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 689a9fb..7f20a1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -27,7 +27,6 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {ClassInfo, classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
@@ -94,8 +93,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -252,12 +249,7 @@
   }
 
   private computeVoteClasses(): ClassInfo {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
-      !this.label ||
-      !this.account ||
-      !hasVoted(this.label, this.account)
-    ) {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
       return {};
     }
     const status = getLabelStatus(this.label, this.vote?.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 48d6998..3fecd63 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -167,9 +167,6 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
@@ -203,7 +200,7 @@
       .group {
         --account-label-suffix: ' (group)';
       }
-      .pending-add {
+      .pendingAdd {
         font-style: italic;
       }
       .list {
@@ -234,8 +231,7 @@
       </div>
       <gr-account-entry
         borderless=""
-        ?hidden=${(this.maxCount && this.maxCount <= this.accounts.length) ||
-        this.readonly}
+        ?hidden=${this.readonly}
         id="entry"
         .placeholder=${this.placeholder}
         @add=${this.handleAdd}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 7b3a93d..26566a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -400,16 +400,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', async () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct, count: 1});
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
-    );
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 48f5d09..3181445 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -22,7 +22,6 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -206,12 +205,6 @@
     ];
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   override render() {
     return html`<paper-button
       ?raised=${!this.link && !this.flatten}
@@ -220,8 +213,7 @@
       tabindex="-1"
       part="paper-button"
       class=${classMap({
-        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
-        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
+        newVoteChip: this.voteChip,
       })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b1d3914..ee3bec7 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -18,11 +18,9 @@
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {
@@ -30,9 +28,7 @@
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
@@ -40,10 +36,8 @@
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
-  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -61,19 +55,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -107,8 +88,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -118,9 +97,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -132,33 +108,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -173,17 +122,10 @@
         gr-button[disabled] iron-icon {
           color: var(--border-color);
         }
-        gr-account-label {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
         iron-icon {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
         }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
-        }
         .reviewer-row {
           padding-top: var(--spacing-s);
         }
@@ -208,14 +150,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
@@ -238,23 +172,6 @@
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
@@ -285,30 +202,6 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-label
-          clickable
-          .account=${mappedLabel.account}
-          .change=${change}
-        ></gr-account-label>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
@@ -341,83 +234,6 @@
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -488,39 +304,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 0ac49a7..f1336b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -20,20 +20,18 @@
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
-import {GrAccountLabel} from '../gr-account-label/gr-account-label';
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -41,12 +39,51 @@
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+      <div class="reviewer-row">
+        <gr-account-chip>
+          <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+        </gr-account-chip>
+        <gr-tooltip-content has-tooltip="" title="Remove vote">
+          <gr-button
+            aria-disabled="false"
+            aria-label="Remove vote"
+            class="deleteBtn hidden"
+            data-account-id="5"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <iron-icon icon="gr-icons:delete"> </iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    </div>`);
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +99,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -108,101 +149,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLabel>(element, 'gr-account-label');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +164,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index 146a01e..5859731 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -23,8 +23,6 @@
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -61,8 +59,6 @@
   @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
   tooltipWithWhoVoted = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -131,9 +127,6 @@
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a5effaf..47f5f81 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -462,7 +462,7 @@
     numLines: number,
     referenceLine: number
   ) {
-    assertIsDefined(this.diff, 'diff');
+    if (!this.diff?.meta_b) return;
     const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 269b56d..d200c75 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -1,24 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
@@ -26,7 +13,6 @@
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 import {CancelablePromise, makeCancelable} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -53,18 +39,35 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent, fire} from '../../../utils/event-util';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {
+  fireAlert,
+  fire,
+  HTMLElementEventDetailType,
+} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {afterNextRender} from '../../../utils/dom-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
 declare global {
   interface HTMLElementEventMap {
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired whenever a new chunk of lines has been rendered synchronously - this
+     * only happens for full renders.
+     */
     'render-progress': CustomEvent<RenderProgressEventDetail>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
   }
 }
 
@@ -97,112 +100,59 @@
   }
 }
 
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement
-  extends PolymerElement
-  implements GroupConsumer
-{
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering - both for full renders and for
-   * partial rerenders.
-   *
-   * @event render-start
-   */
-
-  /**
-   * Fired whenever a new chunk of lines has been rendered synchronously - this
-   * only happens for full renders.
-   *
-   * @event render-progress
-   */
-
-  /**
-   * Fired when the diff finishes rendering text content - both for full
-   * renders and for partial rerenders.
-   *
-   * @event render-content
-   */
-
-  @property({type: Object})
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
   diff?: DiffInfo;
 
-  @property({type: String})
+  diffElement?: HTMLTableElement;
+
   viewMode?: string;
 
-  @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Object})
   baseImage: ImageInfo | null = null;
 
-  @property({type: Object})
   revisionImage: ImageInfo | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
   path?: string;
 
-  @property({type: Object})
   prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
 
-  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
-  _builder?: DiffBuilder;
-
-  /**
-   * The gr-diff-processor adds (and only adds!) to this array. It does so by
-   * using `this.push()` and Polymer's two-way data binding.
-   * Below (@observe('_groups.splices')) we are observing the groups that the
-   * processor adds, and pass them on to the builder for rendering. Henceforth
-   * the builder groups are the source of truth, because when
-   * expanding/collapsing groups only the builder is updated. This field and the
-   * corresponsing one in the processor are not updated.
-   */
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
+  useNewImageDiffUi = false;
 
   /**
    * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
    */
-  @property({type: Array})
   layers: DiffLayer[] = [];
 
+  // visible for testing
+  builder?: DiffBuilder;
+
   /**
-   * All layers, both from the outside and the default ones.
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
    */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
 
-  @property({type: Boolean})
-  _showTabs?: boolean;
+  // visible for testing
+  showTabs?: boolean;
 
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array, observer: 'coverageObserver'})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
+  // visible for testing
+  showTrailingWhitespace?: boolean;
 
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
    * method that rejects it with `{isCancelled: true}`.
    */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
@@ -210,51 +160,20 @@
 
   private rangeLayer = new GrRangedCommentLayer();
 
-  private processor = new GrDiffProcessor();
+  // visible for testing
+  processor = new GrDiffProcessor();
 
   constructor() {
-    super();
-    afterNextRender(this, () => {
-      this.addEventListener(
-        'diff-context-expanded',
-        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
-          // Don't stop propagation. The host may listen for reporting or
-          // resizing.
-          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-        }
-      );
-    });
     this.processor.consumer = this;
   }
 
-  override disconnectedCallback() {
-    this.processor.cancel();
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer.updateRanges(ranges);
   }
 
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
-  }
-
-  @observe('commentRanges.*')
-  rangeObserver() {
-    this.rangeLayer.updateRanges(this.commentRanges);
-  }
-
-  coverageObserver(coverageRanges: CoverageRange[]) {
-    const leftRanges = coverageRanges.filter(
-      range => range && range.side === Side.LEFT
-    );
-    this.coverageLayerLeft.setRanges(leftRanges);
-
-    const rightRanges = coverageRanges.filter(
-      range => range && range.side === Side.RIGHT
-    );
-    this.coverageLayerRight.setRanges(rightRanges);
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
   render(keyLocations: KeyLocations): void {
@@ -262,42 +181,44 @@
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
-    this._setupAnnotationLayers();
+    this.setupAnnotationLayers();
 
-    this._showTabs = this.prefs.show_tabs;
-    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
 
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder();
+    this.builder?.clear();
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    this.builder = this.getDiffBuilder();
 
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
 
-    this._clearDiffContent();
-    this._builder.addColumns(
+    this.diffElement.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+
+    this.clearDiffContent();
+    this.builder.addColumns(
       this.diffElement,
       getLineNumberCellWidth(this.prefs)
     );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = makeCancelable(
+    this.fireDiffEvent('render-start', {});
+    this.cancelableRenderPromise = makeCancelable(
       this.processor
         .process(this.diff.content, isBinary)
         .then(() => {
           if (this.isImageDiff) {
-            (this._builder as GrDiffBuilderImage).renderDiff();
+            (this.builder as GrDiffBuilderImage).renderDiff();
           }
-          afterNextRender(this, () => fireEvent(this, 'render-content'));
+          afterNextRender(() => this.fireDiffEvent('render-content', {}));
         })
         // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
@@ -307,17 +228,39 @@
           return;
         })
         .finally(() => {
-          this._cancelableRenderPromise = null;
+          this.cancelableRenderPromise = null;
         })
     );
   }
 
-  _setupAnnotationLayers() {
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(
+    type: K,
+    detail: HTMLElementEventDetailType<K>
+  ) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fire(this.diffElement, type, detail);
+  }
+
+  private fireDiffEventRenderProgress(detail: RenderProgressEventDetail) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fire(this.diffElement, 'render-progress', detail);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
     const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
       this.rangeLayer,
       this.coverageLayerLeft,
       this.coverageLayerRight,
@@ -326,15 +269,15 @@
     if (this.layers) {
       layers.push(...this.layers);
     }
-    this._layers = layers;
+    this.layersInternal = layers;
   }
 
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
   }
 
-  _getDiffRowByChild(child: Element) {
+  private getDiffRowByChild(child: Element) {
     while (!child.classList.contains('diff-row') && child.parentElement) {
       child = child.parentElement;
     }
@@ -348,23 +291,23 @@
     const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
-    const row = this._getDiffRowByChild(lineEl);
+    const row = this.getDiffRowByChild(lineEl);
     return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this._builder) return null;
-    return this._builder.getLineElByNumber(lineNumber, side);
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
   }
 
   getLineNumberRows() {
-    if (!this._builder) return [];
-    return this._builder.getLineNumberRows();
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
   }
 
   getLineNumEls(side: Side) {
-    if (!this._builder) return [];
-    return this._builder.getLineNumEls(side);
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
   }
 
   /**
@@ -376,8 +319,8 @@
    * @param side The side the line number refer to.
    */
   unhideLine(lineNum: number, side: Side) {
-    if (!this._builder) return;
-    const group = this._builder.findGroup(side, lineNum);
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -420,45 +363,50 @@
     contextGroup: GrDiffGroup,
     newGroups: readonly GrDiffGroup[]
   ) {
-    if (!this._builder) return;
-    fireEvent(this, 'render-start');
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start', {});
     const linesRendered = newGroups.reduce(
       (sum, group) => sum + group.lines.length,
       0
     );
-    this._builder.replaceGroup(contextGroup, newGroups);
-    afterNextRender(this, () => {
-      fire(this, 'render-progress', {linesRendered});
-      fireEvent(this, 'render-content');
+    this.builder.replaceGroup(contextGroup, newGroups);
+    afterNextRender(() => {
+      this.fireDiffEvent('render-progress', {linesRendered});
+      this.fireDiffEvent('render-content', {});
     });
   }
 
   cancel() {
     this.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
+    this.builder?.clear();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
   }
 
-  _handlePreferenceError(pref: string): never {
+  // visible for testing
+  handlePreferenceError(pref: string): never {
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    fireAlert(this, message);
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(): DiffBuilder {
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
+      this.handlePreferenceError('tab size');
     }
 
     if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
+      this.handlePreferenceError('diff width');
     }
 
     const localPrefs = {...this.prefs};
@@ -487,7 +435,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
@@ -495,7 +443,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     }
@@ -505,7 +453,8 @@
     return builder;
   }
 
-  _clearDiffContent() {
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
     this.diffElement.innerHTML = '';
   }
 
@@ -514,22 +463,23 @@
    * server into chunks.
    */
   clearGroups() {
-    if (!this._builder) return;
-    this._builder.clearGroups();
+    if (!this.builder) return;
+    this.builder.clearGroups();
   }
 
   /**
    * Called when the processor is done converting a chunk of the diff.
    */
   addGroup(group: GrDiffGroup) {
-    if (!this._builder) return;
-    this._builder.addGroups([group]);
-    afterNextRender(this, () =>
-      fire(this, 'render-progress', {linesRendered: group.lines.length})
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    afterNextRender(() =>
+      this.fireDiffEventRenderProgress({linesRendered: group.lines.length})
     );
   }
 
-  _createIntralineLayer(): DiffLayer {
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
       // differences to highlight and apply them to the element as
@@ -561,8 +511,9 @@
     };
   }
 
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // If visible tabs are disabled, do nothing.
@@ -576,7 +527,7 @@
     };
   }
 
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // Find and annotate the locations of soft hyphen (\u00AD)
@@ -592,8 +543,9 @@
     };
   }
 
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
@@ -621,18 +573,12 @@
   }
 
   setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame ?? []);
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
+    this.builder?.updateRenderPrefs(renderPrefs);
     this.processor.updateRenderPrefs(renderPrefs);
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
deleted file mode 100644
index 8c15ddd..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1084 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode, Side} from '../../../api/diff.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
-import {waitForEventOnce} from '../../../utils/event-util.js';
-
-const basicFixture = fixtureFromTemplate(html`
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-// GrDiffBuilderElement forces these prefs to be set - tests that do not care
-// about these values can just set these defaults.
-const DEFAULT_PREFS = {
-  line_length: 10,
-  show_tabs: true,
-  tab_size: 4,
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilderLegacy({content: []}, prefs);
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder.createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', async () => {
-      element.diff = {content};
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isFalse(processStub.lastCall.args[1]);
-    });
-
-    test('image', async () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
-    });
-
-    test('binary', async () => {
-      element.diff = {content, binary: true};
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.prefs = prefs;
-      await element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(3)'),
-        outputEl.querySelector('stub:nth-of-type(4)'),
-      ];
-      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      await new Promise(resolve => afterNextRender(element, resolve));
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      const afterNextRenderPromise = new Promise((resolve, reject) => {
-        afterNextRender(element, resolve);
-      });
-      element.diff = {
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      await element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await afterNextRenderPromise;
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton = contextControls[0].shadowRoot
-          .querySelectorAll('.showContext')[0];
-      assert.include(topExpandCommonButton.textContent, '+9 common lines');
-      topExpandCommonButton.click();
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      element.dispatchEvent.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      await new Promise(resolve => afterNextRender(element, resolve));
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = createDiff();
-      element.diff = diff;
-
-      keyLocations = {left: {}, right: {}};
-
-      element.prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      await element.render(keyLocations);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('renderContentByRange', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder.renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('renderContentByRange non-existent elements', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-
-      sinon.stub(builder, 'getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder.renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
-          .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      expect(result).dom.to.equal(/* HTML */`
-        <span class="gr-diff style-scope">
-          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
-            12/12/2019
-          </a>
-          <span class="blameAuthor gr-diff style-scope">Clark</span>
-          <gr-hovercard class="gr-diff style-scope">
-            <span class="blameHoverCard gr-diff style-scope">
-              Commit 1234567890<br>
-              Author: Clark Kent<br>
-              Date: 12/12/2019<br>
-              <br>
-              Testing Commit
-            </span>
-          </gr-hovercard>
-        </span>
-      `);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..8ae08f4
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1131 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {
+  nextRender,
+  queryAndAssert,
+  stubBaseUrl,
+} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="style-scope gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await nextRender();
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+      assert.include(firedEventTypes, 'render-progress');
+
+      await nextRender();
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await nextRender();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await nextRender();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  suite('mock-diff', () => {
+    let builder: GrDiffBuilderSideBySide;
+    let diff: DiffInfo;
+    let keyLocations: KeyLocations;
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      diff = createDiff();
+      element.diff = diff;
+
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.right'
+      )[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.left'
+      )[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+        deltaLineNumberButton.getAttribute('aria-label'),
+        '10 removed'
+      );
+    });
+
+    test('getContentByLine', () => {
+      let actual: HTMLElement | null;
+
+      actual = builder.getContentByLine(2, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(2, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(5, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+      actual = builder.getContentByLine(5, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+    });
+
+    test('getContentTdByLineEl works both with button and td', () => {
+      const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+      const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+      const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonLeft),
+        contentTdLeft
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumTdRight),
+        contentTdRight
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonRight),
+        contentTdRight
+      );
+    });
+
+    test('findLinesByRange', () => {
+      const lines: GrDiffLine[] = [];
+      const elems: HTMLElement[] = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder.renderContentByRange(start, end, Side.LEFT);
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i: number) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('renderContentByRange non-existent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+
+      sinon
+        .stub(builder, 'getLineNumberEl')
+        .returns(document.createElement('div'));
+      sinon
+        .stub(builder, 'findLinesByRange')
+        .callsFake((_1, _2, _3, lines, elements) => {
+          // Add a line and a corresponding element.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          const tr = document.createElement('tr');
+          const td = document.createElement('td');
+          const el = document.createElement('div');
+          tr.appendChild(td);
+          td.appendChild(el);
+          elements?.push(el);
+
+          // Add 2 lines without corresponding elements.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+        });
+
+      builder.renderContentByRange(1, 10, Side.LEFT);
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getLineNumberEl unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(startElem);
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      expect(result).dom.to.equal(/* HTML */ `
+        <span class="gr-diff style-scope">
+          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
+            12/12/2019
+          </a>
+          <span class="blameAuthor gr-diff style-scope">Clark</span>
+          <gr-hovercard class="gr-diff style-scope">
+            <span class="blameHoverCard gr-diff style-scope">
+              Commit 1234567890<br />
+              Author: Clark Kent<br />
+              Date: 12/12/2019<br />
+              <br />
+              Testing Commit
+            </span>
+          </gr-hovercard>
+        </span>
+      `);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index ceadc94..c04d156 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -162,10 +162,8 @@
    *
    * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
    */
-  private getLineNumberEl(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
     let row: HTMLElement | null = content;
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
@@ -349,7 +347,8 @@
     });
   }
 
-  protected createTextEl(
+  // visible for testing
+  createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
     side?: Side
@@ -491,7 +490,8 @@
    * Create a blame cell for the given base line. Blame information will be
    * included in the cell if available.
    */
-  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
     const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
     blameTd.setAttribute('data-line-number', lineNumber.toString());
     if (!lineNumber) return blameTd;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index a711215..f2690bc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -44,7 +44,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup) {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -147,7 +148,8 @@
     return td;
   }
 
-  protected override getNextContentOnSide(
+  // visible for testing
+  override getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4145485..0c9d1d9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -43,7 +43,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 5f3fb72..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..7a9d06d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,282 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index add7ffa..4b664e2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -89,7 +89,8 @@
 
   protected readonly numLinesLeft: number;
 
-  protected readonly _prefs: DiffPreferencesInfo;
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
 
   protected readonly renderPrefs?: RenderPreferences;
 
@@ -194,7 +195,8 @@
     group.element = element;
   }
 
-  private getGroupsByLineRange(
+  // visible for testing
+  getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
     side: Side
@@ -257,7 +259,8 @@
    *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
    *        really need to support null/undefined? Also change to camelCase.
    */
-  protected findLinesByRange(
+  // visible for testing
+  findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
@@ -352,9 +355,8 @@
    *
    * @return The commit information.
    */
-  protected getBlameCommitForBaseLine(
-    lineNum: LineNumber
-  ): BlameInfo | undefined {
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
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 dfe8a15..e80d86b 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
@@ -109,7 +109,8 @@
    */
   initialLineNumber: number | null = null;
 
-  private cursorManager = new GrCursorManager();
+  // visible for testing
+  cursorManager = new GrCursorManager();
 
   private targetSubscription?: Subscription;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index f48d673..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,681 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = await fixture(html`<gr-diff></gr-diff>`);
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = createDiff();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  test('moveToFirstChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {b: ['new line 1']},
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    // The file comment button, if present, is a cursor stop. Ensure
-    // moveToFirstChunk() works correctly even if the button is not shown.
-    diffElement.prefs.show_file_comment_button = false;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-        {b: ['new line 3']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      // We must allow the diff to re-render after setting the viewMode.
-      await new Promise(resolve => afterNextRender(diffElement, resolve));
-      cursor.reInitCursor();
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
-    });
-  });
-
-  suite('moved chunks (moveDetails)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 4, end: 6}},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 2, end: 4}},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    diffElement._diffChanged(createDiff());
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor.reInitCursor();
-    assert.isFalse(moveToNumStub.called);
-    assert.isTrue(moveToChunkStub.called);
-    assert.equal(scrollBehaviorDuringMove, 'never');
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(createDiff());
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor.reInitCursor();
-    assert.isFalse(moveToChunkStub.called);
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], 10);
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    let diffElements;
-
-    setup(async () => {
-      diffElements = [
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-      ];
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(diffElements);
-
-      for (const el of diffElements) {
-        el.prefs = createDefaultDiffPrefs();
-      }
-    });
-
-    function getTargetDiffIndex() {
-      // Mocha has a bug where when `assert.equals` fails, it will try to
-      // JSON.stringify the operands, which fails when they are cyclic structures
-      // like GrDiffElement. The failure is difficult to attribute to a specific
-      // assertion because of the async nature assertion errors are handled and
-      // can cause the test simply timing out, causing a lot of debugging headache.
-      // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursor.getTargetDiffElement());
-    }
-
-    test('do not skip loading diffs', async () => {
-      const diffRenderedPromises =
-          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
-
-      diffElements[0].diff = createDiff();
-      diffElements[2].diff = createDiff();
-      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
-      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
-
-      const lastLine = diffElements[0].diff.meta_b.lines;
-
-      // Goto second last line of the first diff
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = createDiff();
-      await diffRenderedPromises[1];
-      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..ac9b407
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,693 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+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 {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursor.diffRow);
+
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta .diff-row'
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+
+    cursor.moveDown();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+
+    cursor.moveUp();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursor.diffRow);
+
+      const firstDeltaRow = queryAndAssert<HTMLElement>(
+        diffElement,
+        '.section.delta .diff-row'
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+
+      cursor.moveUp();
+
+      assert.notEqual(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta'
+    );
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      firstDeltaSection,
+      '.diff-row'
+    );
+
+    // 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, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(
+      cursor.diffRow!.parentElement,
+      firstDeltaSection.previousSibling
+    );
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const indexOfChunk = function (chunk: HTMLElement) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  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);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
similarity index 62%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 321086c..43a56d1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -1,32 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+import '../../../test/common-test-setup-karma';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    let group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-      l1, l2, l3,
-    ]});
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
@@ -59,7 +52,9 @@
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
     const group = new GrDiffGroup({
-      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -83,34 +78,44 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    assert.throws(() =>
-      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
   });
 
   suite('hideInContextControl', () => {
-    let groups;
+    let groups: GrDiffGroup[];
     setup(() => {
       groups = [
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
-          new GrDiffLine(GrDiffLineType.REMOVE, 10),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
-          new GrDiffLine(GrDiffLineType.REMOVE, 11),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]}),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
       ];
     });
 
@@ -140,21 +145,25 @@
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
 
       assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
 
       assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
@@ -166,19 +175,26 @@
           type: GrDiffGroupType.BOTH,
           skip: 60,
           offsetLeft: 8,
-          offsetRight: 10});
+          offsetRight: 10,
+        });
         groups = [
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
           skipGroup,
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
         ];
       });
 
@@ -189,13 +205,11 @@
     });
 
     test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
     });
 
     test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
     });
   });
 
@@ -206,7 +220,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('is total for remove', () => {
@@ -215,12 +229,12 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('not total for empty', () => {
       const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
 
     test('not total for non-delta', () => {
@@ -229,8 +243,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
   });
 });
-
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 e5f9de0..27952d3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -43,11 +43,7 @@
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
@@ -81,6 +77,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {deepEqual} from '../../../utils/deep-util';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -100,7 +97,6 @@
 
 export interface GrDiff {
   $: {
-    diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
 }
@@ -179,7 +175,7 @@
   @property({type: Object})
   highlightRange?: CommentRange;
 
-  @property({type: Array})
+  @property({type: Array, observer: '_coverageRangesObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean, observer: '_lineWrappingObserver'})
@@ -248,9 +244,6 @@
   @property({type: Object, observer: '_blameChanged'})
   blame: BlameInfo[] | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
@@ -292,11 +285,16 @@
   @property({type: Boolean})
   isAttached = false;
 
-  private renderDiffTableTask?: DelayedTask;
+  // visible for testing
+  renderDiffTableTask?: DelayedTask;
 
   private diffSelection = new GrDiffSelection();
 
-  private highlights = new GrDiffHighlight();
+  // visible for testing
+  highlights = new GrDiffHighlight();
+
+  // visible for testing
+  diffBuilder = new GrDiffBuilderElement();
 
   constructor() {
     super();
@@ -321,11 +319,12 @@
     this._unobserveNodes();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
+    this.diffBuilder.cancel();
     super.disconnectedCallback();
   }
 
   getLineNumEls(side: Side): HTMLElement[] {
-    return this.$.diffBuilder.getLineNumEls(side);
+    return this.diffBuilder.getLineNumEls(side);
   }
 
   showNoChangeMessage(
@@ -426,19 +425,25 @@
           cr.side === removedCommentRange.side &&
           rangesEqual(cr.range, removedCommentRange.range)
       );
-      this.splice('_commentRanges', i, 1);
+      this._commentRanges.splice(i, 1);
     }
 
-    if (addedCommentRanges && addedCommentRanges.length) {
-      this.push('_commentRanges', ...addedCommentRanges);
+    if (addedCommentRanges?.length) {
+      this._commentRanges.push(...addedCommentRanges);
     }
     if (this.highlightRange) {
-      this.push('_commentRanges', {
+      this._commentRanges.push({
         side: Side.RIGHT,
         range: this.highlightRange,
         rootId: '',
       });
     }
+
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+  }
+
+  _coverageRangesObserver() {
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
   }
 
   /**
@@ -483,7 +488,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diffBuilder.cancel();
+    this.diffBuilder.cancel();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -492,7 +497,7 @@
 
     // Get rendered stops.
     const stops: Array<HTMLElement | AbortStop> =
-      this.$.diffBuilder.getLineNumberRows();
+      this.diffBuilder.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -512,7 +517,7 @@
 
   _blameChanged(newValue?: BlameInfo[] | null) {
     if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
+    this.diffBuilder.setBlame(newValue);
     if (newValue) {
       this.classList.add('showBlame');
     } else {
@@ -534,7 +539,7 @@
     return classes.join(' ');
   }
 
-  _handleTap(e: CustomEvent) {
+  _handleTap(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
     if (
@@ -603,7 +608,7 @@
 
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
@@ -621,7 +626,7 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
@@ -663,28 +668,11 @@
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+    if (!deepEqual(newPrefs, oldPrefs)) {
       this._prefsChanged(newPrefs);
     }
   }
 
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
   _pathObserver() {
     // Call _prefsChanged(), because line-limit style value depends on path.
     this._prefsChanged(this.prefs);
@@ -699,7 +687,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.$.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   _cleanup() {
@@ -808,7 +796,7 @@
     if (this.prefs) {
       this._updatePreferenceStyles(this.prefs, renderPrefs);
     }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
+    this.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
@@ -820,7 +808,7 @@
     }
     if (this.diff) {
       this.diffSelection.init(this.diff, this.$.diffTable);
-      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+      this.highlights.init(this.$.diffTable, this.diffBuilder);
     }
   }
 
@@ -866,9 +854,24 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.renderPrefs = this.renderPrefs;
-    this.$.diffBuilder.render(keyLocations);
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.$.diffTable;
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    this.diffBuilder.render(keyLocations);
   }
 
   _handleRenderContent() {
@@ -895,10 +898,7 @@
         const commentSide = getSide(threadEl);
         const range = getRange(threadEl);
         if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
+        const lineEl = this.diffBuilder.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
@@ -911,7 +911,7 @@
           );
           continue;
         }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+        const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
         if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
           contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index 6d36b89..40d4e7f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -698,36 +698,22 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-builder
-      id="diffBuilder"
-      comment-ranges="[[_commentRanges]]"
-      coverage-ranges="[[coverageRanges]]"
-      diff="[[diff]]"
-      path="[[path]]"
-      view-mode="[[viewMode]]"
-      is-image-diff="[[isImageDiff]]"
-      base-image="[[baseImage]]"
-      layers="[[layers]]"
-      revision-image="[[revisionImage]]"
-      use-new-image-diff-ui="[[useNewImageDiffUi]]"
-    >
-      <table
-        id="diffTable"
-        class$="[[_diffTableClass]]"
-        role="presentation"
-        contenteditable$="[[isContentEditable]]"
-      ></table>
+    <table
+      id="diffTable"
+      class$="[[_diffTableClass]]"
+      role="presentation"
+      contenteditable$="[[isContentEditable]]"
+    ></table>
 
-      <template
-        is="dom-if"
-        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-      >
-        <div class="whitespace-change-only-message">
-          This file only contains whitespace changes. Modify the whitespace
-          setting to see the changes.
-        </div>
-      </template>
-    </gr-diff-builder>
+    <template
+      is="dom-if"
+      if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+    >
+      <div class="whitespace-change-only-message">
+        This file only contains whitespace changes. Modify the whitespace
+        setting to see the changes.
+      </div>
+    </template>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
similarity index 62%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index c8b643d..183cdfb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -1,32 +1,37 @@
 /**
  * @license
- * 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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {runA11yAudit} from '../../../test/a11y-test-utils.js';
-import '@polymer/paper-button/paper-button.js';
-import {Side} from '../../../api/diff.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-import {AbortStop} from '../../../api/core.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {waitForEventOnce} from '../../../utils/event-util.js';
+import '../../../test/common-test-setup-karma';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -37,42 +42,51 @@
 });
 
 suite('gr-diff tests', () => {
-  let element;
+  let element: GrDiff;
 
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
 
-  setup(() => {
-
-  });
+  setup(() => {});
 
   suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
       document.dispatchEvent(new CustomEvent('selectionchange'));
     };
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.highlights, 'handleSelectionChange');
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.highlights.handleSelectionChange.called);
+      assert.isTrue(handleSelectionChangeStub.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.highlights.handleSelectionChange.called);
+      assert.isFalse(handleSelectionChangeStub.called);
     });
   });
 
   test('cancel', () => {
     element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
+    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
     element.cancel();
     assert.isTrue(cancelStub.calledOnce);
   });
@@ -98,10 +112,12 @@
     });
 
     test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
+      element.prefs = {...element.prefs!, line_length: 100};
       flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
     });
 
     test('content-width should not be defined', () => {
@@ -123,32 +139,40 @@
     });
 
     test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
     });
 
     test('max-width considers one content column in unified', () => {
-      element.viewMode = 'UNIFIED_DIFF';
+      element.viewMode = DiffViewMode.UNIFIED;
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
     });
 
     test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
+      element.prefs = {...element.prefs!, font_size: 13};
       flush();
       // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
     });
 
     test('sign cols are considered if show_sign_col is true', () => {
       element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
     });
   });
 
@@ -168,39 +192,31 @@
     });
 
     test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
     });
 
     test('displayLine class added called when displayLine is true', () => {
       const spy = sinon.spy(element, '_computeContainerClass');
       element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
       assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
+      assert.isTrue(container.classList.contains('displayLine'));
     });
 
     test('thread groups', () => {
       const contentEl = document.createElement('div');
 
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
       element.path = 'file.txt';
-      element.$.diffBuilder.diff = createDiff();
-      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
 
       // No thread groups.
       assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
 
       // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+      const threadGroupEl = element._getOrCreateThreadGroup(
+        contentEl,
+        Side.LEFT
+      );
       assert.isOk(threadGroupEl);
 
       // The new thread group can be fetched.
@@ -208,17 +224,19 @@
     });
 
     suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
       setup(() => {
         mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
           type: 'image/bmp',
         };
         mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
           type: 'image/bmp',
         };
 
@@ -235,7 +253,6 @@
           show_whitespace_errors: true,
           syntax_highlighting: true,
           tab_size: 8,
-          theme: 'DEFAULT',
         };
       });
 
@@ -244,8 +261,7 @@
         element.revisionImage = mockFile2;
         element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
@@ -262,42 +278,40 @@
 
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
         // Left image rendered with the parent commit's version of the file.
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = query(leftLabel, '.name');
 
-        const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = query(rightLabel, '.name');
 
         assert.isNotOk(rightLabelName);
         assert.isNotOk(leftLabelName);
 
-        assert.isOk(leftImage);
-        assert.equal(leftImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile1.body);
-        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
 
-        assert.isOk(rightImage);
-        assert.equal(rightImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile2.body);
-        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
       });
 
       test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
+        const mockDiff: DiffInfo = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
@@ -312,51 +326,51 @@
         };
 
         element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
+        element.baseImage._name = mockDiff.meta_a!.name;
         element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
+        element.revisionImage._name = mockDiff.meta_b!.name;
         element.diff = mockDiff;
         await waitForEventOnce(element, 'render');
 
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
         // Left image rendered with the parent commit's version of the file.
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = queryAndAssert(leftLabel, '.name');
 
-        const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = queryAndAssert(rightLabel, '.name');
 
         assert.isOk(rightLabelName);
         assert.isOk(leftLabelName);
-        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
 
         assert.isOk(leftImage);
-        assert.equal(leftImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile1.body);
-        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
 
         assert.isOk(rightImage);
-        assert.equal(rightImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile2.body);
-        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp'); // \u00d7 - '×'
       });
 
       test('renders added image', async () => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'ADDED',
           diff_header: [
@@ -371,7 +385,9 @@
         };
 
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.revisionImage = mockFile2;
@@ -380,20 +396,17 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
         assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
+        queryAndAssert(diffTable, 'td.right img');
       });
 
       test('renders removed image', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'DELETED',
           diff_header: [
@@ -407,7 +420,9 @@
           binary: true,
         };
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
@@ -416,20 +431,21 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
+        const diffTable = element.$.diffTable;
+        queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
         assert.isNotOk(rightImage);
       });
 
       test('does not render disallowed image type', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
           intraline_status: 'OK',
           change_type: 'DELETED',
           diff_header: [
@@ -445,7 +461,9 @@
         mockFile1.type = 'image/jpeg-evil';
 
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
@@ -454,9 +472,9 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
         assert.isNotOk(leftImage);
       });
     });
@@ -513,7 +531,6 @@
           show_tabs: true,
           show_whitespace_errors: true,
           syntax_highlighting: true,
-          theme: 'DEFAULT',
           ignore_whitespace: 'IGNORE_NONE',
         };
 
@@ -548,20 +565,20 @@
         const FILE_ROW = 1;
         const actual = element.getCursorStops();
         assert.equal(actual.length, ROWS + FILE_ROW + 1);
-        assert.isTrue(actual[actual.length -1] instanceof AbortStop);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
       });
     });
 
     test('adds .hiddenscroll', () => {
       _setHiddenScroll(true);
       element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.include(container.className, 'hiddenscroll');
     });
   });
 
   suite('logged in', () => {
-    let fakeLineEl;
+    let fakeLineEl: HTMLElement;
     setup(() => {
       element = basicFixture.instantiate();
       element.loggedIn = true;
@@ -571,15 +588,14 @@
         classList: {
           contains: sinon.stub().returns(true),
         },
-      };
+      } as unknown as HTMLElement;
     });
 
     test('addDraftAtLine', () => {
       sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
+      const createCommentStub = sinon.stub(element, '_createComment');
       element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
     });
 
     test('adds long range comment hint', async () => {
@@ -592,23 +608,29 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('line-num', '1');
       threadEl.setAttribute('range', JSON.stringify(range));
       threadEl.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
       setupSampleDiff({content});
-      await new Promise(resolve => afterNextRender(element, resolve));
+      await waitForEventOnce(element, 'render');
 
       element.appendChild(threadEl);
       await flush();
 
-      assert.deepEqual(
-          element.querySelector('gr-ranged-comment-hint').range, range);
+      const hint = queryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
     });
 
     test('no duplicate range hint for same thread', async () => {
@@ -621,19 +643,21 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('line-num', '1');
       threadEl.setAttribute('range', JSON.stringify(range));
       threadEl.setAttribute('slot', 'right-1');
       const firstHint = document.createElement('gr-ranged-comment-hint');
       firstHint.range = range;
-      firstHint.setAttribute('threadElRootId', threadEl.rootId);
       firstHint.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
       setupSampleDiff({content});
 
       element.appendChild(firstHint);
@@ -644,86 +668,97 @@
       await flush();
 
       assert.equal(
-          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        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 = [{
-            a: [],
-            b: [],
-          }, {
-            ab: Array(8).fill('text'),
-          }];
-          setupSampleDiff({content});
-          element.appendChild(threadEl);
-          await flush();
+    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 = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      element.appendChild(threadEl);
+      await flush();
 
-          threadEl.remove();
-          await flush();
+      threadEl.remove();
+      await flush();
 
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-        });
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
 
     suite('change in preferences', () => {
       setup(() => {
         element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           diff_header: [],
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           content: [{skip: 66}],
         };
-        element.renderDiffTableTask.flush();
+        element.renderDiffTableTask?.flush();
       });
 
       test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
+        const stub = sinon.stub(element, '_renderDiffTable');
         element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+          ...MINIMAL_PREFS,
+        };
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
       });
 
       test('adding/removing property in preferences re-renders diff', () => {
         const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
         element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
         stub.reset();
 
         const newPrefs2 = {...newPrefs1};
         delete newPrefs2.line_wrapping;
         element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
       });
 
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        () => {
+          const stub = sinon.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
     });
   });
 
@@ -732,8 +767,7 @@
       element = basicFixture.instantiate();
       element.diff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
         diff_header: [],
         intraline_status: 'OK',
         change_type: 'MODIFIED',
@@ -755,11 +789,12 @@
       assert.equal(element._diffHeaderItems.length, 1);
       flush();
 
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
     });
 
     test('binary files', () => {
-      element.diff.binary = true;
+      element.diff!.binary = true;
       assert.equal(element._diffHeaderItems.length, 0);
       element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
       assert.equal(element._diffHeaderItems.length, 0);
@@ -771,16 +806,17 @@
   });
 
   suite('safety and bypass', () => {
-    let renderStub;
+    let renderStub: sinon.SinonStub;
 
     setup(() => {
       element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        const diffTable = element.$.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve({});
+      });
       sinon.stub(element, 'getDiffLength').returns(10000);
       element.diff = createDiff();
       element.noRenderOnPrefsChange = true;
@@ -838,7 +874,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element._safetyBypass, -1);
-      assert.equal(element.$.diffBuilder.prefs.context, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -851,7 +887,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element._safetyBypass);
-      assert.equal(element.$.diffBuilder.prefs.context, 3);
+      assert.equal(element.diffBuilder.prefs.context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -863,7 +899,7 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element._safetyBypass, 10);
-      assert.equal(element.$.diffBuilder.prefs.context, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
     });
   });
 
@@ -874,7 +910,7 @@
 
     test('unsetting', () => {
       element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
       element.classList.add('showBlame');
       element.blame = null;
       assert.isTrue(setBlameSpy.calledWithExactly(null));
@@ -882,7 +918,15 @@
     });
 
     test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
       assert.isTrue(element.classList.contains('showBlame'));
     });
   });
@@ -891,8 +935,10 @@
     const NO_NEWLINE_LEFT = 'No newline at end of left file.';
     const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
 
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
+    const getWarning = (element: GrDiff) => {
+      const warningElement = queryAndAssert(element, '.newlineWarning');
+      return warningElement.textContent;
+    };
 
     setup(() => {
       element = basicFixture.instantiate();
@@ -904,8 +950,9 @@
       element.showNewlineWarningLeft = true;
       element.showNewlineWarningRight = true;
       assert.include(
-          getWarning(element),
-          NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT);// \u2014 - '—'
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
     });
 
     suite('showNewlineWarningLeft', () => {
@@ -918,11 +965,6 @@
         element.showNewlineWarningLeft = false;
         assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
       });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
     });
 
     suite('showNewlineWarningRight', () => {
@@ -935,49 +977,25 @@
         element.showNewlineWarningRight = false;
         assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
       });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
     });
 
     test('_computeNewlineWarningClass', () => {
       const hidden = 'newlineWarning hidden';
       const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+      assert.equal(element._computeNewlineWarningClass(false, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(false, false), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, false), shown);
     });
   });
 
   suite('key locations', () => {
-    let renderStub;
+    let renderStub: sinon.SinonStub;
 
     setup(() => {
       element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
     });
 
     test('lineOfInterest is a key location', () => {
@@ -994,7 +1012,7 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
+      threadEl.setAttribute('line-num', '3');
       element.appendChild(threadEl);
       flush();
 
@@ -1021,7 +1039,11 @@
       });
     });
   });
-  const setupSampleDiff = function(params) {
+  const setupSampleDiff = function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
     const {ignore_whitespace, content} = params;
     // binary can't be undefined, use false if not set
     const binary = params.binary || false;
@@ -1039,7 +1061,6 @@
       show_whitespace_errors: true,
       syntax_highlighting: true,
       tab_size: 8,
-      theme: 'DEFAULT',
     };
     element.diff = {
       intraline_status: 'OK',
@@ -1059,21 +1080,24 @@
   };
 
   test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
     function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
+      const diffTable = element.$.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
     }
     setupSampleDiff({content});
     assertDiffTableWithContent();
-    element.diff = {...element.diff};
+    element.diff = {...element.diff!};
     // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
+    const diffTable = element.$.diffTable;
+    assert.equal(diffTable.innerHTML, '');
     element._renderDiffTable();
     flush();
     // rendered again
@@ -1082,40 +1106,46 @@
 
   suite('selection test', () => {
     test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
       flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
+      mouseDown(diffLine);
       assert.equal(getComputedStyle(diffLine).userSelect, 'text');
     });
 
     test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
+      element.viewMode = DiffViewMode.UNIFIED;
       flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
+      mouseDown(diffLine);
       assert.equal(getComputedStyle(diffLine).userSelect, 'text');
     });
   });
@@ -1123,71 +1153,87 @@
   suite('whitespace changes only message', () => {
     test('show the message if ignore_whitespace is criteria matches', () => {
       setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
+      assert.isTrue(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message for binary files', () => {
       setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message if still loading', () => {
       setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ true,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
       assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
   });
 
@@ -1195,21 +1241,4 @@
     const diff = createDiff();
     assert.equal(element.getDiffLength(diff), 52);
   });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
 });
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index bb46484..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {createDiffAppContext} from './gr-diff-app-context-init.js';
-
-suite('gr diff app context initializer tests', () => {
-  test('all services initialized and are singletons', () => {
-    const appContext = createDiffAppContext();
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..84fd859
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup-karma';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index c276f79..a34f880 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -9,14 +9,21 @@
   NumericChangeId,
   ChangeStatus,
   ReviewerState,
+  AccountId,
   AccountInfo,
+  GroupInfo,
 } from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
-import {ReviewInput, ReviewerInput} from '../../types/common';
+import {
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+} from '../../types/common';
+import {accountOrGroupKey} from '../../utils/account-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -154,14 +161,15 @@
   }
 
   addReviewers(
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
   ): Promise<Response>[] {
     const current = this.subject$.getValue();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
     );
     return changes.map(change => {
-      const reviewersNewToChange = [
+      const reviewersNewToChange: ReviewerInput[] = [
         ReviewerState.REVIEWER,
         ReviewerState.CC,
       ].flatMap(state =>
@@ -170,8 +178,20 @@
       if (reviewersNewToChange.length === 0) {
         return Promise.resolve(new Response());
       }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
       const reviewInput: ReviewInput = {
         reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
       };
       return this.restApiService.saveChangeReview(
         change._number,
@@ -242,14 +262,14 @@
   private getNewReviewersToChange(
     change: ChangeInfo,
     state: ReviewerState,
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
   ): ReviewerInput[] {
     return (
       changedReviewers
         .get(state)
         ?.filter(account => !change.reviewers[state]?.includes(account))
         .map(account => {
-          return {state, reviewer: account._account_id!};
+          return {state, reviewer: accountOrGroupKey(account)};
         }) ?? []
     );
   }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 5347b41..84d5c4e 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -7,6 +7,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
   createRevisions,
 } from '../../test/test-data-generators';
 import {
@@ -18,6 +19,7 @@
   AccountInfo,
   ReviewerState,
   AccountId,
+  GroupInfo,
 } from '../../api/rest-api';
 import {BulkActionsModel, LoadingState} from './bulk-actions-model';
 import {getAppContext} from '../../services/app-context';
@@ -200,6 +202,7 @@
       createAccountWithIdNameAndEmail(0),
       createAccountWithIdNameAndEmail(1),
     ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
     const changes: ChangeInfo[] = [
       {
         ...createChange(),
@@ -234,21 +237,49 @@
     test('adds reviewers/cc only to changes that need it', async () => {
       bulkActionsModel.addReviewers(
         new Map([
-          [ReviewerState.REVIEWER, [accounts[0]]],
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
           [ReviewerState.CC, [accounts[1]]],
-        ])
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
       );
 
-      // changes[0] is not updated since it already has the reviewer & CC
-      assert.isTrue(saveChangeReviewStub.calledOnce);
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
         changes[1]._number,
         'current',
         {
           reviewers: [
             {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index a1b732f..44d63d4b 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -28,7 +28,6 @@
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
-  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 5f77e8a..028b2af 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -62,7 +62,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -100,37 +100,37 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
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 2b4fc60..3193833 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
@@ -2702,20 +2702,22 @@
 
     return Promise.all([promiseA, promiseB]).then(results => {
       // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
       const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
       return imagesForDiff;
     });
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index e7de1ef..74b6997 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -73,7 +73,7 @@
             initializing = true;
             initialized.set(name, factory(context));
           } catch (e) {
-            console.error(`Failed to initialize ${name}`, e);
+            console.error(`Failed to initialize ${String(name)}`, e);
           } finally {
             initializing = false;
           }
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * This is an "abstract" class for tests. The descendant must define a template
- * for this element and a tagName - see createCommentApiMockWithTemplateElement below
- */
-class CommentApiMock extends LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 829a36b..8e8fe42 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -469,6 +469,24 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
 export function createDiff(): DiffInfo {
   return {
     meta_a: {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 985bec1..ea7865e 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -27,7 +27,7 @@
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {afterNextRender, Key, Modifier} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
 import {HighlightService} from '../services/highlight/highlight-service';
@@ -224,6 +224,10 @@
   return waitUntil(() => stub.called, `${name} was not called`);
 }
 
+export async function nextRender() {
+  return new Promise(resolve => afterNextRender(resolve));
+}
+
 /**
  * Subscribes to the observable and resolves once it emits a matching value.
  * Usage:
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 562d47f..7ad656d 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -48,9 +48,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  meta_a?: DiffFileMetaInfo;
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index b7cc77b..b6018ba 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,7 +35,7 @@
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 6ccf770..95b753c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -159,7 +159,8 @@
 
 /**
  * Returns the elements that are present in every sub-array. If a compareBy
- * predicate is passed in, it will be used instead of strict equality.
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
  */
 export function intersection<T>(
   arrays: T[][],
@@ -171,6 +172,9 @@
   if (arrays.length === 0) {
     return [];
   }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
   return arrays.reduce((result, array) =>
     result.filter(t => array.find(u => compareBy(t, u)))
   );
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 8cc523a..0adfaa6 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -75,8 +75,11 @@
   });
 
   test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
     assert.sameDeepMembers(intersection([]), []);
-    assert.sameDeepMembers(intersection([[1, 2, 3]]), [1, 2, 3]);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
     assert.sameDeepMembers(
       intersection([
         [1, 2, 3],
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16e0586..f2e0994 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -505,3 +505,14 @@
   });
   obs.observe(el);
 }
+
+/**
+ * Mimics a Polymer utility. `requestAnimationFrame` is called before the next
+ * browser paint. An additional `setTimeout` ensures that the paint has
+ * actually happened.
+ */
+export function afterNextRender(callback: (value?: unknown) => void) {
+  requestAnimationFrame(() => {
+    setTimeout(callback);
+  });
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 418adbd..e624cef 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -32,7 +32,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index f5703f4..2b6f700 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -21,7 +21,6 @@
   SubmitRequirementStatus,
   LabelNameToValuesMap,
 } from '../api/rest-api';
-import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -421,16 +420,3 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
-
-export function showNewSubmitRequirements(
-  flagsService: FlagsService,
-  change?: ParsedChangeInfo | ChangeInfo
-) {
-  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-  if (!isSubmitRequirementsUiEnabled) return false;
-  if ((getRequirements(change) ?? []).length === 0) return false;
-
-  return true;
-}
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
index da77234..6c645f4 100644
--- a/tools/js/template_checker.bzl
+++ b/tools/js/template_checker.bzl
@@ -123,9 +123,7 @@
     )
 
     # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir.
     pkg_tar(
         name = name + "_tar",
         srcs = generated_dev_files,