Merge "Add the functionality to include inline attachments to EmailSender."
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index cdfc779..3e53678 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -7,6 +7,8 @@
 [verse]
 --
 _ssh_ -p <port> <host> _gerrit version_
+  [--verbose | -v]
+  [--json]
 --
 
 == DESCRIPTION
@@ -31,6 +33,14 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+--verbose::
+-v::
+  Verbose output, include also the NoteDb version and the version of each index.
+
+--json::
+  Json output format. Assumes verbose output.
+
 == EXAMPLES
 
 ----
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index fe9b13c..a347d6c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -30,6 +30,32 @@
   "2.7"
 ----
 
+The `verbose` option can be used to provide a verbose version output as
+link:#version-info[VersionInfo].
+
+.Request
+----
+  GET /config/server/version?verbose HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit_version": "3.8.0",
+    "note_db_version": 185,
+    "change_index_version": 83,
+    "account_index_version": 13,
+    "project_index_version": 6,
+    "group_index_version": 10
+  }
+----
+
+
+
 [[get-info]]
 === Get Server Info
 --
@@ -1968,6 +1994,22 @@
 details.
 |=======================================
 
+[[version-info]]
+=== VersionInfo
+The `VersionInfo` entity contains information about the version of the
+Gerrit server.
+
+[options="header",cols="1,^1,5"]
+|=======================================
+|Field Name                ||Description
+|`gerrit_version`          ||Gerrit server version
+|`note_db_version`         ||NoteDb version
+|`change_index_version`    ||Change index version
+|`account_index_version`   ||Account index version
+|`project_index_version`   ||Project index version
+|`group_index_version`     ||Group index version
+|=======================================
+
 [[server-info]]
 === ServerInfo
 The `ServerInfo` entity contains information about the configuration of
diff --git a/WORKSPACE b/WORKSPACE
index 29266cd..ad34969 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -112,8 +112,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
+    sha256 = "94070eff79305be05b7699207fbac5d2608054dd53e6109f7d00d923919ff45a",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
diff --git a/java/com/google/gerrit/extensions/common/VersionInfo.java b/java/com/google/gerrit/extensions/common/VersionInfo.java
new file mode 100644
index 0000000..f18e1cc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/VersionInfo.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2023 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.extensions.common;
+
+public class VersionInfo {
+  public String gerritVersion;
+  public int noteDbVersion;
+  public int changeIndexVersion;
+  public int accountIndexVersion;
+  public int projectIndexVersion;
+  public int groupIndexVersion;
+
+  public String compact() {
+    return "gerrit version " + gerritVersion + "\n";
+  }
+
+  public String verbose() {
+    StringBuilder s = new StringBuilder();
+    s.append("gerrit version " + gerritVersion).append("\n");
+    s.append("NoteDb version " + noteDbVersion).append("\n");
+    s.append("Index versions\n");
+    String format = "  %-8s %3d\n";
+    s.append(String.format(format, "changes", changeIndexVersion));
+    s.append(String.format(format, "accounts", accountIndexVersion));
+    s.append(String.format(format, "projects", projectIndexVersion));
+    s.append(String.format(format, "groups", groupIndexVersion));
+    return s.toString();
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 728606d..b94e840 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -113,7 +113,7 @@
   private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
   static Term idTerm(ChangeData cd) {
-    return idTerm(cd.getId());
+    return idTerm(cd.getVirtualId());
   }
 
   static Term idTerm(Change.Id id) {
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index df64bc7..8523e8a 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -39,6 +40,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/version",
         "//java/com/google/gerrit/sshd",
         "//lib:args4j",
         "//lib:guava",
@@ -56,5 +58,6 @@
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
+        "@gson//jar",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/Version.java b/java/com/google/gerrit/pgm/Version.java
index 2392be5..27c52d3 100644
--- a/java/com/google/gerrit/pgm/Version.java
+++ b/java/com/google/gerrit/pgm/Version.java
@@ -14,18 +14,40 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.pgm.util.AbstractProgram;
+import com.google.gerrit.server.version.VersionInfoModule;
+import org.kohsuke.args4j.Option;
 
 /** Display the version of Gerrit. */
 public class Version extends AbstractProgram {
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  private boolean verbose;
+
+  @Option(name = "--json", usage = "json output format, assumes verbose output")
+  private boolean json;
+
   @Override
   public int run() throws Exception {
-    final String v = com.google.gerrit.common.Version.getVersion();
-    if (v == null) {
+    VersionInfo versionInfo = new VersionInfoModule().createVersionInfo();
+    if (versionInfo.gerritVersion == null) {
       System.err.println("fatal: version unavailable");
       return 1;
     }
-    System.out.println("gerrit version " + v);
+
+    if (json) {
+      System.out.println(OutputFormat.JSON.newGson().toJson(versionInfo));
+    } else if (verbose) {
+      System.out.print(versionInfo.verbose());
+    } else {
+      System.out.print(versionInfo.compact());
+    }
+
     return 0;
   }
 }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index d68f809..53b6c95 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -155,6 +155,7 @@
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/version",
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index 9a75469..834a623 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -99,8 +99,8 @@
     }
 
     List<ChangeData> cds =
-        InternalChangeQuery.byBranchGroups(
-            queryProvider, indexConfig, changeData.change().getDest(), groups);
+        InternalChangeQuery.byProjectGroups(
+            queryProvider, indexConfig, changeData.project(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
     }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index e9912f5..f32ba02 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -224,6 +224,7 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.version.VersionInfoModule;
 import com.google.gitiles.blame.cache.BlameCache;
 import com.google.gitiles.blame.cache.BlameCacheImpl;
 import com.google.inject.Inject;
@@ -290,6 +291,7 @@
     install(ThreadLocalRequestContext.module());
     install(new ApprovalModule());
     install(new MailSoySauceModule());
+    install(new VersionInfoModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
index f3f7645..2a74833 100644
--- a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -14,24 +14,24 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 
-public class GerritImportedServerIdsProvider implements Provider<ImmutableSet<String>> {
+public class GerritImportedServerIdsProvider implements Provider<ImmutableList<String>> {
   public static final String SECTION = "gerrit";
   public static final String KEY = "importedServerId";
 
-  private final ImmutableSet<String> importedIds;
+  private final ImmutableList<String> importedIds;
 
   @Inject
   public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
-    importedIds = ImmutableSet.copyOf(cfg.getStringList(SECTION, null, KEY));
+    importedIds = ImmutableList.copyOf(cfg.getStringList(SECTION, null, KEY));
   }
 
   @Override
-  public ImmutableSet<String> get() {
+  public ImmutableList<String> get() {
     return importedIds;
   }
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 14aadf47..678f4d0 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -294,8 +294,8 @@
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
-        InternalChangeQuery.byBranchGroups(
-            queryProvider, indexConfig, change.getDest(), currentPs.groups())) {
+        InternalChangeQuery.byProjectGroups(
+            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ps.commitId());
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 1d5818a..7057ff7 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -128,7 +128,7 @@
           .required()
           // The numeric change id is integer in string form
           .size(10)
-          .build(cd -> String.valueOf(cd.getId().get()));
+          .build(cd -> String.valueOf(cd.getVirtualId().get()));
 
   public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
       NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 2edea26..5ffb5fb 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,7 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -53,7 +53,7 @@
     public final AllUsersName allUsers;
     public final NoteDbMetrics metrics;
     public final String serverId;
-    public final ImmutableSet<String> importedServerIds;
+    public final ImmutableList<String> importedServerIds;
 
     // Providers required to avoid dependency cycles.
 
@@ -68,7 +68,7 @@
         NoteDbMetrics metrics,
         Provider<ChangeNotesCache> cache,
         @GerritServerId String serverId,
-        @GerritImportedServerIds ImmutableSet<String> importedServerIds) {
+        @GerritImportedServerIds ImmutableList<String> importedServerIds) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 01c2708..80c8085 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -74,6 +74,7 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtilFactory;
@@ -262,15 +263,65 @@
    * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
    * fields that can be set.
    *
+   * @param project project name
    * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
    * @return instance for testing.
    */
   public static ChangeData createForTest(
       Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
+    return createForTest(project, id, currentPatchSetId, commitId, null, null, null);
+  }
+
+  /**
+   * Create an instance for testing only.
+   *
+   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
+   * fields that can be set.
+   *
+   * @param project project name
+   * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
+   * @param serverId Gerrit server id
+   * @param virtualIdAlgo algorithm for virtualising the Change number
+   * @param changeNotes notes associated with the Change
+   * @return instance for testing.
+   */
+  public static ChangeData createForTest(
+      Project.NameKey project,
+      Change.Id id,
+      int currentPatchSetId,
+      ObjectId commitId,
+      @Nullable String serverId,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgo,
+      @Nullable ChangeNotes changeNotes) {
     ChangeData cd =
         new ChangeData(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, project, id, null, null);
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            serverId,
+            virtualIdAlgo,
+            project,
+            id,
+            null,
+            changeNotes);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -363,6 +414,9 @@
   private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
+  private String gerritServerId;
+  private String changeServerId;
+  private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
 
   @Inject
   private ChangeData(
@@ -383,6 +437,8 @@
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      @GerritServerId String gerritServerId,
+      ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
       @Assisted @Nullable Change change,
@@ -410,6 +466,10 @@
 
     this.change = change;
     this.notes = notes;
+
+    this.changeServerId = notes == null ? null : notes.getServerId();
+    this.gerritServerId = gerritServerId;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   /**
@@ -530,6 +590,14 @@
     return legacyId;
   }
 
+  public Change.Id getVirtualId() {
+    if (virtualIdFunc == null || changeServerId == null || changeServerId.equals(gerritServerId)) {
+      return legacyId;
+    }
+
+    return Change.id(virtualIdFunc.apply(changeServerId, legacyId.get()));
+  }
+
   public Project.NameKey project() {
     return project;
   }
@@ -560,6 +628,7 @@
       throw new StorageException("Unable to load change " + legacyId, e);
     }
     change = notes.getChange();
+    changeServerId = notes.getServerId();
     setPatchSets(null);
     return change;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
new file mode 100644
index 0000000..726a376
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -0,0 +1,76 @@
+// 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.query.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+/**
+ * Dictionary-based encoding algorithm for combining a serverId/legacyChangeNum into a virtual
+ * numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@Singleton
+public class ChangeNumberBitmapMaskAlgorithm implements ChangeNumberVirtualIdAlgorithm {
+  /*
+   * Bit-wise masks for representing the Change's VirtualId as combination of ServerId + ChangeNum:
+   */
+  private static final int CHANGE_NUM_BIT_LEN = 28; // Allows up to 268M changes
+  private static final int LEGACY_ID_BIT_MASK = (1 << CHANGE_NUM_BIT_LEN) - 1;
+  private static final int SERVER_ID_BIT_LEN =
+      Integer.BYTES * 8 - CHANGE_NUM_BIT_LEN; // Allows up to 64 ServerIds
+
+  private final ImmutableMap<String, Integer> serverIdCodes;
+
+  @Inject
+  public ChangeNumberBitmapMaskAlgorithm(
+      @GerritImportedServerIds ImmutableList<String> importedServerIds) {
+    if (importedServerIds.size() >= 1 << SERVER_ID_BIT_LEN) {
+      throw new ProvisionException(
+          String.format(
+              "Too many imported GerritServerIds (%d) to fit into the Change virtual id",
+              importedServerIds.size()));
+    }
+    ImmutableMap.Builder<String, Integer> serverIdCodesBuilder = new ImmutableMap.Builder<>();
+    for (int i = 0; i < importedServerIds.size(); i++) {
+      serverIdCodesBuilder.put(importedServerIds.get(i), i + 1);
+    }
+
+    serverIdCodes = serverIdCodesBuilder.build();
+  }
+
+  @Override
+  public int apply(String changeServerId, int changeNum) {
+    if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Change number %d is too large to be converted into a virtual id", changeNum));
+    }
+
+    Integer encodedServerId = serverIdCodes.get(changeServerId);
+    if (encodedServerId == null) {
+      throw new IllegalArgumentException(
+          String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+    }
+    int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+    return virtualId;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
new file mode 100644
index 0000000..ab21705
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
@@ -0,0 +1,35 @@
+// 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.query.change;
+
+import com.google.inject.ImplementedBy;
+
+/**
+ * Algorithm for encoding a serverId/legacyChangeNum into a virtual numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@ImplementedBy(ChangeNumberBitmapMaskAlgorithm.class)
+public interface ChangeNumberVirtualIdAlgorithm {
+
+  /**
+   * Convert a serverId/legacyChangeNum tuple into a virtual numeric id
+   *
+   * @param serverId Gerrit serverId
+   * @param legacyChangeNum legacy change number
+   * @return virtual id which combines serverId and legacyChangeNum together
+   */
+  int apply(String serverId, int legacyChangeNum);
+}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 3edad69..62c070c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -264,21 +264,21 @@
     return query(ChangePredicates.submissionId(cs));
   }
 
-  private static Predicate<ChangeData> byBranchGroupsPredicate(
-      IndexConfig indexConfig, BranchNameKey branchAndProject, Collection<String> groups) {
-    int n = indexConfig.maxTerms() - 2;
+  private static Predicate<ChangeData> byProjectGroupsPredicate(
+      IndexConfig indexConfig, Project.NameKey project, Collection<String> groups) {
+    int n = indexConfig.maxTerms() - 1;
     checkArgument(groups.size() <= n, "cannot exceed %s groups", n);
     List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
     for (String g : groups) {
       groupPredicates.add(new GroupPredicate(g));
     }
-    return and(project(branchAndProject.project()), ref(branchAndProject), or(groupPredicates));
+    return and(project(project), or(groupPredicates));
   }
 
-  public static ImmutableList<ChangeData> byBranchGroups(
+  public static ImmutableList<ChangeData> byProjectGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
-      BranchNameKey branchAndProject,
+      Project.NameKey project,
       Collection<String> groups) {
     // These queries may be complex along multiple dimensions:
     //  * Many groups per change, if there are very many patch sets. This requires partitioning the
@@ -289,17 +289,16 @@
     // InternalChangeQuery is single-use.
 
     Supplier<InternalChangeQuery> querySupplier = () -> queryProvider.get().enforceVisibility(true);
-    int batchSize = indexConfig.maxTerms() - 2;
+    int batchSize = indexConfig.maxTerms() - 1;
     if (groups.size() <= batchSize) {
       return queryExhaustively(
-          querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, groups));
+          querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
     ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
-          queryExhaustively(
-              querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, part))) {
+          queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
         if (!seen.add(cd.getId())) {
           result.add(cd);
         }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 3e985c2..0f2aa3c 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -92,6 +92,7 @@
     post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
     postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
     get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
+    get(CHANGE_KIND, "custom_keyed_values").to(GetCustomKeyedValues.class);
     get(CHANGE_KIND, "comments").to(ListChangeComments.class);
     get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
     get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
@@ -103,6 +104,7 @@
     delete(CHANGE_KIND).to(DeleteChange.class);
     post(CHANGE_KIND, "abandon").to(Abandon.class);
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
+    post(CHANGE_KIND, "custom_keyed_values").to(PostCustomKeyedValues.class);
     post(CHANGE_KIND, "restore").to(Restore.class);
     post(CHANGE_KIND, "revert").to(Revert.class);
     post(CHANGE_KIND, "revert_submission").to(RevertSubmission.class);
diff --git a/java/com/google/gerrit/server/restapi/config/GetVersion.java b/java/com/google/gerrit/server/restapi/config/GetVersion.java
index ee206d6..6b205e4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetVersion.java
+++ b/java/com/google/gerrit/server/restapi/config/GetVersion.java
@@ -14,23 +14,40 @@
 
 package com.google.gerrit.server.restapi.config;
 
-import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.inject.Singleton;
+import com.google.inject.Inject;
 import java.util.concurrent.TimeUnit;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetVersion implements RestReadView<ConfigResource> {
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  boolean verbose;
+
+  private final VersionInfo versionInfo;
+
+  @Inject
+  public GetVersion(VersionInfo versionInfo) {
+    this.versionInfo = versionInfo;
+  }
+
   @Override
-  public Response<String> apply(ConfigResource resource) throws ResourceNotFoundException {
-    String version = Version.getVersion();
-    if (version == null) {
+  public Response<?> apply(ConfigResource resource) throws ResourceNotFoundException {
+    if (versionInfo.gerritVersion == null) {
       throw new ResourceNotFoundException();
     }
-    return Response.ok(version).caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
+    if (verbose) {
+      return Response.ok(versionInfo).caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
+    }
+    return Response.ok(versionInfo.gerritVersion)
+        .caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index e0e64a3..9593522 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,7 +16,7 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -55,7 +55,7 @@
         .toProvider(GerritServerIdProvider.class)
         .in(SINGLETON);
 
-    bind(new TypeLiteral<ImmutableSet<String>>() {})
+    bind(new TypeLiteral<ImmutableList<String>>() {})
         .annotatedWith(GerritImportedServerIds.class)
         .toProvider(GerritImportedServerIdsProvider.class)
         .in(SINGLETON);
diff --git a/java/com/google/gerrit/server/version/BUILD b/java/com/google/gerrit/server/version/BUILD
new file mode 100644
index 0000000..c7f659c
--- /dev/null
+++ b/java/com/google/gerrit/server/version/BUILD
@@ -0,0 +1,17 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "version",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "@guice-library//jar",
+    ],
+)
diff --git a/java/com/google/gerrit/server/version/VersionInfoModule.java b/java/com/google/gerrit/server/version/VersionInfoModule.java
new file mode 100644
index 0000000..e6dea71
--- /dev/null
+++ b/java/com/google/gerrit/server/version/VersionInfoModule.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2023 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.version;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.schema.NoteDbSchemaVersions;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+public class VersionInfoModule extends AbstractModule {
+  @Provides
+  @Singleton
+  public VersionInfo createVersionInfo() {
+    VersionInfo v = new VersionInfo();
+    v.gerritVersion = Version.getVersion();
+    v.noteDbVersion = NoteDbSchemaVersions.LATEST;
+    v.changeIndexVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.accountIndexVersion = AccountSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.projectIndexVersion = ProjectSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.groupIndexVersion = GroupSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    return v;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index af7078d..7a11131c 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -21,7 +21,9 @@
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/version",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
         "//lib:args4j",
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index f8771fb..c274b3d 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -16,21 +16,40 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "version", description = "Display gerrit version", runsAt = MASTER_OR_SLAVE)
 final class VersionCommand extends SshCommand {
 
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  private boolean verbose;
+
+  @Option(name = "--json", usage = "json output format, assumes verbose output")
+  private boolean json;
+
+  @Inject private VersionInfo versionInfo;
+
   @Override
   protected void run() throws Failure {
     enableGracefulStop();
-    String v = Version.getVersion();
-    if (v == null) {
+    if (versionInfo.gerritVersion == null) {
       throw new Failure(1, "fatal: version unavailable");
     }
 
-    stdout.println("gerrit version " + v);
+    if (json) {
+      stdout.println(OutputFormat.JSON.newGson().toJson(versionInfo));
+    } else if (verbose) {
+      stdout.print(versionInfo.verbose());
+    } else {
+      stdout.print(versionInfo.compact());
+    }
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index e86fd09..52ac58a 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,7 +19,7 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
@@ -324,9 +324,9 @@
   @Provides
   @Singleton
   @GerritImportedServerIds
-  public ImmutableSet<String> createImportedServerIds() {
-    ImmutableSet<String> serverIds =
-        ImmutableSet.copyOf(
+  public ImmutableList<String> createImportedServerIds() {
+    ImmutableList<String> serverIds =
+        ImmutableList.copyOf(
             cfg.getStringList(
                 GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
     return serverIds;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 27bd6b9..2df820b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -60,6 +60,7 @@
           RestCall.delete("/changes/%s/topic"),
           RestCall.get("/changes/%s/in"),
           RestCall.get("/changes/%s/hashtags"),
+          RestCall.get("/changes/%s/custom_keyed_values"),
           RestCall.get("/changes/%s/comments"),
           RestCall.get("/changes/%s/robotcomments"),
           RestCall.get("/changes/%s/drafts"),
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 3e03b2a..e15ed08 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -40,13 +40,11 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
@@ -71,8 +69,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 @NoHttpd
@@ -670,71 +666,6 @@
     }
   }
 
-  @Test
-  public void getRelatedLinearSameCommitPushedTwice() throws Exception {
-    RevCommit base = projectOperations.project(project).getHead("master");
-
-    // 1,1---2,1 on master
-    PushOneCommit.Result r1 =
-        createChange(
-            testRepo,
-            "master",
-            "subject: 1",
-            "a.txt",
-            "1",
-            /** topic= */
-            null);
-    RevCommit c1_1 = r1.getCommit();
-    PatchSet.Id ps1_1 = r1.getPatchSetId();
-
-    PushOneCommit.Result r2 =
-        createChange(
-            testRepo,
-            "master",
-            "subject: 2",
-            "b.txt",
-            "2",
-            /** topic= */
-            null);
-    RevCommit c2_1 = r2.getCommit();
-    PatchSet.Id ps2_1 = r2.getPatchSetId();
-
-    // 3,1---4,1 on stable
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-    testRepo.reset(c1_1);
-    PushResult r3 = pushHead(testRepo, "refs/for/stable%base=" + base.getName());
-    assertThat(r3.getRemoteUpdate("refs/for/stable%base=" + base.getName()).getStatus())
-        .isEqualTo(RemoteRefUpdate.Status.OK);
-    ChangeData change3 =
-        Iterables.getOnlyElement(
-            queryProvider
-                .get()
-                .byBranchCommit(BranchNameKey.create(project, "stable"), c1_1.getName()));
-    assertThat(change3.currentPatchSet().commitId()).isEqualTo(c1_1);
-    RevCommit c3_1 = c1_1;
-    PatchSet.Id ps3_1 = change3.currentPatchSet().id();
-
-    PushOneCommit.Result r4 =
-        createChange(
-            testRepo,
-            "stable",
-            "subject: 4",
-            "d.txt",
-            "4",
-            /** topic= */
-            null);
-    RevCommit c4_1 = r4.getCommit();
-    PatchSet.Id ps4_1 = r4.getPatchSetId();
-
-    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
-    }
-
-    for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps3_1)) {
-      assertRelated(ps, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps3_1, c3_1, 1));
-    }
-  }
-
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.transforming(
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 35077db..59b354c 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -157,14 +157,16 @@
   @Test
   public void tolerateNullValuesForInsertion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
   @Test
   public void tolerateNullValuesForDeletion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
         .isTrue();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index bf6839d..a7ec4c6 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -19,7 +19,6 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -185,9 +184,9 @@
             install(new DefaultRefLogIdentityProvider.Module());
             bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
             bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
-            bind(new TypeLiteral<ImmutableSet<String>>() {})
+            bind(new TypeLiteral<ImmutableList<String>>() {})
                 .annotatedWith(GerritImportedServerIds.class)
-                .toInstance(new ImmutableSet.Builder<String>().add(importedServerIds).build());
+                .toInstance(new ImmutableList.Builder<String>().add(importedServerIds).build());
             bind(GitRepositoryManager.class).toInstance(repoManager);
             bind(ProjectCache.class).to(NullProjectCache.class);
             bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index f80a96d..0ce00eb 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -15,18 +15,29 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import java.util.UUID;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class ChangeDataTest {
+  private static final String GERRIT_SERVER_ID = UUID.randomUUID().toString();
+
+  @Mock private ChangeNotes changeNotesMock;
+
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
     Project.NameKey project = Project.nameKey("project");
@@ -41,6 +52,26 @@
     assertThat(curr2).isNotSameInstanceAs(curr1);
   }
 
+  @Test
+  public void getChangeVirtualIdUsingAlgorithm() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    final int encodedChangeNum = 12345678;
+
+    when(changeNotesMock.getServerId()).thenReturn(UUID.randomUUID().toString());
+
+    ChangeData cd =
+        ChangeData.createForTest(
+            project,
+            Change.id(1),
+            1,
+            ObjectId.zeroId(),
+            GERRIT_SERVER_ID,
+            (s, c) -> encodedChangeNum,
+            changeNotesMock);
+
+    assertThat(cd.getVirtualId().get()).isEqualTo(encodedChangeNum);
+  }
+
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
     return PatchSet.builder()
         .id(PatchSet.id(changeId, num))
diff --git a/package.json b/package.json
index 362b9dc..7b310aa 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
 {
   "name": "gerrit",
-  "version": "3.1.0-SNAPSHOT",
+  "version": "3.9.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/concatjs": "^5.5.0",
-    "@bazel/rollup": "^5.5.0",
-    "@bazel/terser": "^5.5.0",
-    "@bazel/typescript": "^5.5.0"
+    "@bazel/concatjs": "^5.8.0",
+    "@bazel/rollup": "^5.8.0",
+    "@bazel/terser": "^5.8.0",
+    "@bazel/typescript": "^5.8.0"
   },
   "devDependencies": {
     "@koa/cors": "^3.3.0",
@@ -18,7 +18,7 @@
     "eslint-config-google": "^0.14.0",
     "eslint-plugin-html": "^6.2.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.6.4",
+    "eslint-plugin-jsdoc": "^44.2.4",
     "eslint-plugin-lit": "^1.6.1",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-prettier": "^4.0.0",
diff --git a/plugins/.eslintrc.js b/plugins/.eslintrc.js
index 149a31e..920ec2a 100644
--- a/plugins/.eslintrc.js
+++ b/plugins/.eslintrc.js
@@ -186,8 +186,6 @@
     'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     'jsdoc/match-description': 0,
-    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
diff --git a/plugins/package.json b/plugins/package.json
index 504fc17..b99a9e8 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -6,7 +6,7 @@
     "@gerritcodereview/typescript-api": "3.8.0",
     "@polymer/decorators": "^3.0.0",
     "@polymer/polymer": "^3.4.1",
-    "@open-wc/testing": "^3.1.6",
+    "@open-wc/testing": "^3.1.8",
     "@web/dev-server-esbuild": "^0.3.2",
     "@web/test-runner": "^0.14.0",
     "@codemirror/autocomplete": "^6.5.1",
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3f53453..1a82f27 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -501,26 +501,26 @@
     "@types/chai" "^4.3.1"
     "@web/test-runner-commands" "^0.6.1"
 
-"@open-wc/testing-helpers@^2.1.4":
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.4.tgz#4b439442ecb1ea3fbcbb1ef76e8717574d78dc97"
-  integrity sha512-iZJxxKI9jRgnPczm8p2jpuvBZ3DHYSLrBmhDfzs7ol8vXMNt+HluzM1j1TSU95MFVGnfaspvvt9fMbXKA7cNcA==
+"@open-wc/testing-helpers@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.2.1.tgz#10ed75c33faec0ed68c76e027ebe8be262a36921"
+  integrity sha512-8zuJK7tUQYuXRIC/cVcPbAPOhtBJCe3Jfpk7im7WK0DIAXH9Q/ycB+yu3R8g4BQ31f/FdLjIFRbPZzIU75kkRg==
   dependencies:
     "@open-wc/scoped-elements" "^2.1.3"
     lit "^2.0.0"
     lit-html "^2.0.0"
 
-"@open-wc/testing@^3.1.6":
-  version "3.1.7"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.7.tgz#65200c759626d510fda103c3cb4ede6202b1b88b"
-  integrity sha512-HCS2LuY6hXtEwjqmad+eanId5H7E+3mUi9Z3rjAhH+1DCJ53lUnjzWF1lbCYbREqrdCpmzZvW1t5R3e9gJZSCA==
+"@open-wc/testing@^3.1.8":
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.8.tgz#3760a354e421a38bf432010a067ff3d3fdb60b1e"
+  integrity sha512-SpKhlSwCqUkVOOmdb9RanOQgqv4T32wzExkvuaVcUFcBeUdpwQsg1+WYpdv31Z4cRCkAhQ4A8OIpGphzqF8T7w==
   dependencies:
     "@esm-bundle/chai" "^4.3.4-fix.0"
     "@open-wc/chai-dom-equals" "^0.12.36"
     "@open-wc/semantic-dom-diff" "^0.19.7"
-    "@open-wc/testing-helpers" "^2.1.4"
+    "@open-wc/testing-helpers" "^2.2.1"
     "@types/chai" "^4.2.11"
-    "@types/chai-dom" "^0.0.12"
+    "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
     chai-a11y-axe "^1.3.2"
 
@@ -621,10 +621,10 @@
     "@types/connect" "*"
     "@types/node" "*"
 
-"@types/chai-dom@^0.0.12":
-  version "0.0.12"
-  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
-  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+"@types/chai-dom@^1.11.0":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.0.tgz#e9bd01f3408b2ffd27755fe4418ff92ffd8f4e66"
+  integrity sha512-Aja99Mmnny+Sz+T2hBK3oEsrcy18yabplT0pGX/QwIke9jMJHdvHlV2f4Tmq5SqxTMYwt1Zjbisv/4r83EUIHw==
   dependencies:
     "@types/chai" "*"
 
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index e18a3af..7abc658 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -176,8 +176,6 @@
     'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     'jsdoc/match-description': 0,
-    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 7df755b..a125b8f 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@gerritcodereview/typescript-api",
-  "version": "3.8.0",
+  "version": "3.9.0-SNAPSHOT",
   "description": "Gerrit Code Review - TypeScript API",
   "homepage": "https://www.gerritcodereview.com/",
   "browser": true,
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index ff7a528..0e75b84 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -142,65 +142,67 @@
     }
   }
 
-  static override styles = [
-    sharedStyles,
-    paperStyles,
-    formStyles,
-    menuPageStyles,
-    css`
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-m);
-      }
-      .header {
-        align-items: baseline;
-        display: flex;
-        justify-content: space-between;
-        margin: var(--spacing-s) var(--spacing-m);
-      }
-      .rules {
-        background: var(--table-header-background-color);
-        border: 1px solid var(--border-color);
-        border-bottom: 0;
-      }
-      .editing .rules {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .title {
-        margin-bottom: var(--spacing-s);
-      }
-      #addRule,
-      #removeBtn {
-        display: none;
-      }
-      .right {
-        display: flex;
-        align-items: center;
-      }
-      .editing #removeBtn {
-        display: block;
-        margin-left: var(--spacing-xl);
-      }
-      .editing #addRule {
-        display: block;
-        padding: var(--spacing-m);
-      }
-      #deletedContainer,
-      .deleted #mainContainer {
-        display: none;
-      }
-      .deleted #deletedContainer {
-        align-items: baseline;
-        border: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m);
-      }
-      #mainContainer {
-        display: block;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      formStyles,
+      menuPageStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .header {
+          align-items: baseline;
+          display: flex;
+          justify-content: space-between;
+          margin: var(--spacing-s) var(--spacing-m);
+        }
+        .rules {
+          background: var(--table-header-background-color);
+          border: 1px solid var(--border-color);
+          border-bottom: 0;
+        }
+        .editing .rules {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .title {
+          margin-bottom: var(--spacing-s);
+        }
+        #addRule,
+        #removeBtn {
+          display: none;
+        }
+        .right {
+          display: flex;
+          align-items: center;
+        }
+        .editing #removeBtn {
+          display: block;
+          margin-left: var(--spacing-xl);
+        }
+        .editing #addRule {
+          display: block;
+          padding: var(--spacing-m);
+        }
+        #deletedContainer,
+        .deleted #mainContainer {
+          display: none;
+        }
+        .deleted #deletedContainer {
+          align-items: baseline;
+          border: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m);
+        }
+        #mainContainer {
+          display: block;
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.section || !this.permission) {
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 1cb25ea..8d72a97 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
@@ -212,104 +212,106 @@
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
-  static override styles = [
-    sharedStyles,
-    fontStyles,
-    changeMetadataStyles,
-    css`
-      :host {
-        display: table;
-      }
-      gr-submit-requirements {
-        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
-      }
-      gr-editable-label {
-        max-width: 9em;
-      }
-      gr-weblink {
-        display: block;
-      }
-      gr-account-chip[disabled],
-      gr-linked-chip[disabled] {
-        opacity: 0;
-        pointer-events: none;
-      }
-      .hashtagChip {
-        padding-bottom: var(--spacing-s);
-      }
-      /* consistent with section .title, .value */
-      .hashtagChip:not(last-of-type) {
-        padding-bottom: var(--spacing-s);
-      }
-      .hashtagChip:last-of-type {
-        display: inline;
-        vertical-align: top;
-      }
-      .parentList.merge {
-        list-style-type: decimal;
-        padding-left: var(--spacing-l);
-      }
-      .parentList gr-commit-info {
-        display: inline-block;
-      }
-      .hideDisplay,
-      #parentNotCurrentMessage {
-        display: none;
-      }
-      .icon {
-        margin: -3px 0;
-      }
-      .icon.help,
-      .icon.notTrusted {
-        color: var(--warning-foreground);
-      }
-      .icon.invalid {
-        color: var(--negative-red-text-color);
-      }
-      .icon.trusted {
-        color: var(--positive-green-text-color);
-      }
-      .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-        --arrow-color: var(--warning-foreground);
-        display: inline-block;
-      }
-      .oldSeparatedSection {
-        margin-top: var(--spacing-l);
-        padding: var(--spacing-m) 0;
-      }
-      .separatedSection {
-        padding: var(--spacing-m) 0;
-      }
-      .hashtag gr-linked-chip,
-      .topic gr-linked-chip {
-        --linked-chip-text-color: var(--link-color);
-      }
-      gr-reviewer-list {
-        --account-max-length: 100px;
-        max-width: 285px;
-      }
-      .metadata-title {
-        color: var(--deemphasized-text-color);
-        padding-left: var(--metadata-horizontal-padding);
-      }
-      .metadata-header {
-        display: flex;
-        justify-content: space-between;
-        align-items: flex-end;
-        /* The goal is to achieve alignment of the owner account chip and the
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      changeMetadataStyles,
+      css`
+        :host {
+          display: table;
+        }
+        gr-submit-requirements {
+          --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+        }
+        gr-editable-label {
+          max-width: 9em;
+        }
+        gr-weblink {
+          display: block;
+        }
+        gr-account-chip[disabled],
+        gr-linked-chip[disabled] {
+          opacity: 0;
+          pointer-events: none;
+        }
+        .hashtagChip {
+          padding-bottom: var(--spacing-s);
+        }
+        /* consistent with section .title, .value */
+        .hashtagChip:not(last-of-type) {
+          padding-bottom: var(--spacing-s);
+        }
+        .hashtagChip:last-of-type {
+          display: inline;
+          vertical-align: top;
+        }
+        .parentList.merge {
+          list-style-type: decimal;
+          padding-left: var(--spacing-l);
+        }
+        .parentList gr-commit-info {
+          display: inline-block;
+        }
+        .hideDisplay,
+        #parentNotCurrentMessage {
+          display: none;
+        }
+        .icon {
+          margin: -3px 0;
+        }
+        .icon.help,
+        .icon.notTrusted {
+          color: var(--warning-foreground);
+        }
+        .icon.invalid {
+          color: var(--negative-red-text-color);
+        }
+        .icon.trusted {
+          color: var(--positive-green-text-color);
+        }
+        .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+          --arrow-color: var(--warning-foreground);
+          display: inline-block;
+        }
+        .oldSeparatedSection {
+          margin-top: var(--spacing-l);
+          padding: var(--spacing-m) 0;
+        }
+        .separatedSection {
+          padding: var(--spacing-m) 0;
+        }
+        .hashtag gr-linked-chip,
+        .topic gr-linked-chip {
+          --linked-chip-text-color: var(--link-color);
+        }
+        gr-reviewer-list {
+          --account-max-length: 100px;
+          max-width: 285px;
+        }
+        .metadata-title {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        .metadata-header {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
+          /* The goal is to achieve alignment of the owner account chip and the
          commit message box. Their top border should be on the same line. */
-        margin-bottom: var(--spacing-s);
-      }
-      .show-all-button gr-icon {
-        color: inherit;
-        font-size: 18px;
-      }
-      gr-vote-chip {
-        --gr-vote-chip-width: 14px;
-        --gr-vote-chip-height: 14px;
-      }
-    `,
-  ];
+          margin-bottom: var(--spacing-s);
+        }
+        .show-all-button gr-icon {
+          color: inherit;
+          font-size: 18px;
+        }
+        gr-vote-chip {
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.change) return nothing;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 7723327..77a2cf5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -27,23 +27,25 @@
    * @event cancel
    */
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: 0.5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 891209e..080eb0c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -154,75 +154,77 @@
     }
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: 0.5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      .main label,
-      .main input[type='text'] {
-        display: block;
-        width: 100%;
-      }
-      iron-autogrow-textarea {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        width: 73ch; /* Add a char to account for the border. */
-      }
-      .cherryPickTopicLayout {
-        display: flex;
-        align-items: center;
-        margin-bottom: var(--spacing-m);
-      }
-      .cherryPickSingleChange,
-      .cherryPickTopic {
-        margin-left: var(--spacing-m);
-      }
-      .cherry-pick-topic-message {
-        margin-bottom: var(--spacing-m);
-      }
-      label[for='messageInput'],
-      label[for='baseInput'] {
-        margin-top: var(--spacing-m);
-      }
-      .title {
-        font-weight: var(--font-weight-bold);
-      }
-      tr > td {
-        padding: var(--spacing-m);
-      }
-      th {
-        color: var(--deemphasized-text-color);
-      }
-      table {
-        border-collapse: collapse;
-      }
-      tr {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .error {
-        color: var(--error-text-color);
-      }
-      .error-message {
-        color: var(--error-text-color);
-        margin: var(--spacing-m) 0 var(--spacing-m) 0;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        .main label,
+        .main input[type='text'] {
+          display: block;
+          width: 100%;
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+        .cherryPickTopicLayout {
+          display: flex;
+          align-items: center;
+          margin-bottom: var(--spacing-m);
+        }
+        .cherryPickSingleChange,
+        .cherryPickTopic {
+          margin-left: var(--spacing-m);
+        }
+        .cherry-pick-topic-message {
+          margin-bottom: var(--spacing-m);
+        }
+        label[for='messageInput'],
+        label[for='baseInput'] {
+          margin-top: var(--spacing-m);
+        }
+        .title {
+          font-weight: var(--font-weight-bold);
+        }
+        tr > td {
+          padding: var(--spacing-m);
+        }
+        th {
+          color: var(--deemphasized-text-color);
+        }
+        table {
+          border-collapse: collapse;
+        }
+        tr {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .error {
+          color: var(--error-text-color);
+        }
+        .error-message {
+          color: var(--error-text-color);
+          margin: var(--spacing-m) 0 var(--spacing-m) 0;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 6ad416e..7bbfd93 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -160,39 +160,41 @@
     }
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: block;
-        width: 30em;
-      }
-      :host([disabled]) {
-        opacity: 0.5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-      }
-      .message {
-        font-style: italic;
-      }
-      .parentRevisionContainer label,
-      .parentRevisionContainer input[type='text'] {
-        display: block;
-        width: 100%;
-      }
-      .rebaseCheckbox {
-        margin-top: var(--spacing-m);
-      }
-      .rebaseOption {
-        margin: var(--spacing-m) 0;
-      }
-      .rebaseOnBehalfMsg {
-        margin-top: var(--spacing-m);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          width: 30em;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+        }
+        .message {
+          font-style: italic;
+        }
+        .parentRevisionContainer label,
+        .parentRevisionContainer input[type='text'] {
+          display: block;
+          width: 100%;
+        }
+        .rebaseCheckbox {
+          margin-top: var(--spacing-m);
+        }
+        .rebaseOption {
+          margin: var(--spacing-m) 0;
+        }
+        .rebaseOnBehalfMsg {
+          margin-top: var(--spacing-m);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index e421c9c..c01d89f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -66,43 +66,45 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: 0.5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-        display: block;
-        width: 100%;
-      }
-      .revertSubmissionLayout {
-        display: flex;
-        align-items: center;
-      }
-      .label {
-        margin-left: var(--spacing-m);
-      }
-      iron-autogrow-textarea {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        width: 73ch; /* Add a char to account for the border. */
-      }
-      .error {
-        color: var(--error-text-color);
-        margin-bottom: var(--spacing-m);
-      }
-      label[for='messageInput'] {
-        margin-top: var(--spacing-m);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+          display: block;
+          width: 100%;
+        }
+        .revertSubmissionLayout {
+          display: flex;
+          align-items: center;
+        }
+        .label {
+          margin-left: var(--spacing-m);
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+        .error {
+          color: var(--error-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        label[for='messageInput'] {
+          margin-top: var(--spacing-m);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index adea275..f6f3706 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -139,99 +139,101 @@
     );
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      .prefsButton {
-        float: right;
-      }
-      .patchInfoOldPatchSet.patchInfo-header {
-        background-color: var(--emphasis-color);
-      }
-      .patchInfo-header {
-        align-items: center;
-        display: flex;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .patchInfo-left {
-        align-items: baseline;
-        display: flex;
-      }
-      .patchInfoContent {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-      }
-      .latestPatchContainer a {
-        text-decoration: none;
-      }
-      .mobile {
-        display: none;
-      }
-      .patchInfo-header .container {
-        align-items: center;
-        display: flex;
-      }
-      .downloadContainer,
-      .uploadContainer {
-        margin-right: 16px;
-      }
-      .uploadContainer.hide {
-        display: none;
-      }
-      .rightControls {
-        align-self: flex-end;
-        margin: auto 0 auto auto;
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-        font-weight: var(--font-weight-normal);
-        justify-content: flex-end;
-      }
-      #collapseBtn,
-      .allExpanded #expandBtn,
-      .fileViewActions {
-        display: none;
-      }
-      .someExpanded #expandBtn {
-        margin-right: 8px;
-      }
-      .someExpanded #collapseBtn,
-      .allExpanded #collapseBtn,
-      .openFile .fileViewActions {
-        align-items: center;
-        display: flex;
-      }
-      .rightControls gr-button,
-      gr-patch-range-select {
-        margin: 0 -4px;
-      }
-      .fileViewActions gr-button {
-        margin: 0;
-        --gr-button-padding: 2px 4px;
-      }
-      .flexContainer {
-        align-items: center;
-        display: flex;
-      }
-      .label {
-        font-weight: var(--font-weight-bold);
-        margin-right: 24px;
-      }
-      gr-commit-info,
-      gr-edit-controls {
-        margin-right: -5px;
-      }
-      .fileViewActionsLabel {
-        margin-right: var(--spacing-xs);
-      }
-      @media screen and (max-width: 50em) {
-        .patchInfo-header .desktop {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .prefsButton {
+          float: right;
+        }
+        .patchInfoOldPatchSet.patchInfo-header {
+          background-color: var(--emphasis-color);
+        }
+        .patchInfo-header {
+          align-items: center;
+          display: flex;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .patchInfo-left {
+          align-items: baseline;
+          display: flex;
+        }
+        .patchInfoContent {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .latestPatchContainer a {
+          text-decoration: none;
+        }
+        .mobile {
           display: none;
         }
-      }
-    `,
-  ];
+        .patchInfo-header .container {
+          align-items: center;
+          display: flex;
+        }
+        .downloadContainer,
+        .uploadContainer {
+          margin-right: 16px;
+        }
+        .uploadContainer.hide {
+          display: none;
+        }
+        .rightControls {
+          align-self: flex-end;
+          margin: auto 0 auto auto;
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+          font-weight: var(--font-weight-normal);
+          justify-content: flex-end;
+        }
+        #collapseBtn,
+        .allExpanded #expandBtn,
+        .fileViewActions {
+          display: none;
+        }
+        .someExpanded #expandBtn {
+          margin-right: 8px;
+        }
+        .someExpanded #collapseBtn,
+        .allExpanded #collapseBtn,
+        .openFile .fileViewActions {
+          align-items: center;
+          display: flex;
+        }
+        .rightControls gr-button,
+        gr-patch-range-select {
+          margin: 0 -4px;
+        }
+        .fileViewActions gr-button {
+          margin: 0;
+          --gr-button-padding: 2px 4px;
+        }
+        .flexContainer {
+          align-items: center;
+          display: flex;
+        }
+        .label {
+          font-weight: var(--font-weight-bold);
+          margin-right: 24px;
+        }
+        gr-commit-info,
+        gr-edit-controls {
+          margin-right: -5px;
+        }
+        .fileViewActionsLabel {
+          margin-right: var(--spacing-xs);
+        }
+        @media screen and (max-width: 50em) {
+          .patchInfo-header .desktop {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.change || !this.diffPrefs) {
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 f41236b..a9c472f 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
@@ -354,223 +354,225 @@
 
   private readonly shortcuts = new ShortcutController(this);
 
-  static override styles = [
-    sharedStyles,
-    modalStyles,
-    css`
-      :host {
-        background-color: var(--dialog-background-color);
-        display: block;
-        max-height: 90vh;
-        --label-score-padding-left: var(--spacing-xl);
-      }
-      :host([disabled]) {
-        pointer-events: none;
-      }
-      :host([disabled]) .container {
-        opacity: 0.5;
-      }
-      section {
-        border-top: 1px solid var(--border-color);
-        flex-shrink: 0;
-        padding: var(--spacing-m) var(--spacing-xl);
-        width: 100%;
-      }
-      section.labelsContainer {
-        /* We want the :hover highlight to extend to the border of the dialog. */
-        padding: var(--spacing-m) 0;
-      }
-      .stickyBottom {
-        background-color: var(--dialog-background-color);
-        box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
-        margin-top: var(--spacing-s);
-        bottom: 0;
-        position: sticky;
-        /* @see Issue 8602 */
-        z-index: 1;
-      }
-      .stickyBottom.newReplyDialog {
-        margin-top: unset;
-      }
-      .actions {
-        display: flex;
-        justify-content: space-between;
-      }
-      .actions .right gr-button {
-        margin-left: var(--spacing-l);
-      }
-      .peopleContainer,
-      .labelsContainer {
-        flex-shrink: 0;
-      }
-      .peopleContainer {
-        border-top: none;
-        display: table;
-      }
-      .peopleList {
-        display: flex;
-      }
-      .peopleListLabel {
-        color: var(--deemphasized-text-color);
-        margin-top: var(--spacing-xs);
-        min-width: 6em;
-        padding-right: var(--spacing-m);
-      }
-      gr-account-list {
-        display: flex;
-        flex-wrap: wrap;
-        flex: 1;
-      }
-      #reviewerConfirmationModal {
-        padding: var(--spacing-l);
-        text-align: center;
-      }
-      .reviewerConfirmationButtons {
-        margin-top: var(--spacing-l);
-      }
-      .groupName {
-        font-weight: var(--font-weight-bold);
-      }
-      .groupSize {
-        font-style: italic;
-      }
-      .textareaContainer {
-        min-height: 12em;
-        position: relative;
-      }
-      .newReplyDialog.textareaContainer {
-        min-height: unset;
-      }
-      textareaContainer,
-      gr-endpoint-decorator[name='reply-text'] {
-        display: flex;
-        width: 100%;
-      }
-      gr-endpoint-decorator[name='reply-text'] {
-        flex-direction: column;
-      }
-      #checkingStatusLabel,
-      #notLatestLabel {
-        margin-left: var(--spacing-l);
-      }
-      #checkingStatusLabel {
-        color: var(--deemphasized-text-color);
-        font-style: italic;
-      }
-      #notLatestLabel,
-      #savingLabel {
-        color: var(--error-text-color);
-      }
-      #savingLabel {
-        display: none;
-      }
-      #savingLabel.saving {
-        display: inline;
-      }
-      #pluginMessage {
-        color: var(--deemphasized-text-color);
-        margin-left: var(--spacing-l);
-        margin-bottom: var(--spacing-m);
-      }
-      #pluginMessage:empty {
-        display: none;
-      }
-      .attention .edit-attention-button {
-        vertical-align: top;
-        --gr-button-padding: 0px 4px;
-      }
-      .attention .edit-attention-button gr-icon {
-        color: inherit;
-        /* The line-height:26px hack (see below) requires us to do this.
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          background-color: var(--dialog-background-color);
+          display: block;
+          max-height: 90vh;
+          --label-score-padding-left: var(--spacing-xl);
+        }
+        :host([disabled]) {
+          pointer-events: none;
+        }
+        :host([disabled]) .container {
+          opacity: 0.5;
+        }
+        section {
+          border-top: 1px solid var(--border-color);
+          flex-shrink: 0;
+          padding: var(--spacing-m) var(--spacing-xl);
+          width: 100%;
+        }
+        section.labelsContainer {
+          /* We want the :hover highlight to extend to the border of the dialog. */
+          padding: var(--spacing-m) 0;
+        }
+        .stickyBottom {
+          background-color: var(--dialog-background-color);
+          box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+          margin-top: var(--spacing-s);
+          bottom: 0;
+          position: sticky;
+          /* @see Issue 8602 */
+          z-index: 1;
+        }
+        .stickyBottom.newReplyDialog {
+          margin-top: unset;
+        }
+        .actions {
+          display: flex;
+          justify-content: space-between;
+        }
+        .actions .right gr-button {
+          margin-left: var(--spacing-l);
+        }
+        .peopleContainer,
+        .labelsContainer {
+          flex-shrink: 0;
+        }
+        .peopleContainer {
+          border-top: none;
+          display: table;
+        }
+        .peopleList {
+          display: flex;
+        }
+        .peopleListLabel {
+          color: var(--deemphasized-text-color);
+          margin-top: var(--spacing-xs);
+          min-width: 6em;
+          padding-right: var(--spacing-m);
+        }
+        gr-account-list {
+          display: flex;
+          flex-wrap: wrap;
+          flex: 1;
+        }
+        #reviewerConfirmationModal {
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .reviewerConfirmationButtons {
+          margin-top: var(--spacing-l);
+        }
+        .groupName {
+          font-weight: var(--font-weight-bold);
+        }
+        .groupSize {
+          font-style: italic;
+        }
+        .textareaContainer {
+          min-height: 12em;
+          position: relative;
+        }
+        .newReplyDialog.textareaContainer {
+          min-height: unset;
+        }
+        textareaContainer,
+        gr-endpoint-decorator[name='reply-text'] {
+          display: flex;
+          width: 100%;
+        }
+        gr-endpoint-decorator[name='reply-text'] {
+          flex-direction: column;
+        }
+        #checkingStatusLabel,
+        #notLatestLabel {
+          margin-left: var(--spacing-l);
+        }
+        #checkingStatusLabel {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+        }
+        #notLatestLabel,
+        #savingLabel {
+          color: var(--error-text-color);
+        }
+        #savingLabel {
+          display: none;
+        }
+        #savingLabel.saving {
+          display: inline;
+        }
+        #pluginMessage {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-l);
+          margin-bottom: var(--spacing-m);
+        }
+        #pluginMessage:empty {
+          display: none;
+        }
+        .attention .edit-attention-button {
+          vertical-align: top;
+          --gr-button-padding: 0px 4px;
+        }
+        .attention .edit-attention-button gr-icon {
+          color: inherit;
+          /* The line-height:26px hack (see below) requires us to do this.
            Normally the gr-icon would account for a proper positioning
            within the standard line-height:20px context. */
-        top: 5px;
-      }
-      .attention a,
-      .attention-detail a {
-        text-decoration: none;
-      }
-      .attentionSummary {
-        display: flex;
-        justify-content: space-between;
-      }
-      .attentionSummary {
-        /* The account label for selection is misbehaving currently: It consumes
+          top: 5px;
+        }
+        .attention a,
+        .attention-detail a {
+          text-decoration: none;
+        }
+        .attentionSummary {
+          display: flex;
+          justify-content: space-between;
+        }
+        .attentionSummary {
+          /* The account label for selection is misbehaving currently: It consumes
           26px height instead of 20px, which is the default line-height and thus
           the max that can be nicely fit into an inline layout flow. We
           acknowledge that using a fixed 26px value here is a hack and not a
           great solution. */
-        line-height: 26px;
-      }
-      .attentionSummary gr-account-label,
-      .attention-detail gr-account-label {
-        --account-max-length: 120px;
-        display: inline-block;
-        padding: var(--spacing-xs) var(--spacing-m);
-        user-select: none;
-        --label-border-radius: 8px;
-      }
-      .attentionSummary gr-account-label {
-        margin: 0 var(--spacing-xs);
-        line-height: var(--line-height-normal);
-        vertical-align: top;
-      }
-      .attention-detail .peopleListValues {
-        line-height: calc(var(--line-height-normal) + 10px);
-      }
-      .attention-detail gr-account-label {
-        line-height: var(--line-height-normal);
-      }
-      .attentionSummary gr-account-label:focus,
-      .attention-detail gr-account-label:focus {
-        outline: none;
-      }
-      .attentionSummary gr-account-label:hover,
-      .attention-detail gr-account-label:hover {
-        box-shadow: var(--elevation-level-1);
-        cursor: pointer;
-      }
-      .attention-detail .attentionDetailsTitle {
-        display: flex;
-        justify-content: space-between;
-      }
-      .attention-detail .selectUsers {
-        color: var(--deemphasized-text-color);
-        margin-bottom: var(--spacing-m);
-      }
-      .attentionTip {
-        padding: var(--spacing-m);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        margin-top: var(--spacing-m);
-        background-color: var(--line-item-highlight-color);
-      }
-      .attentionTip div gr-icon {
-        margin-right: var(--spacing-s);
-      }
-      .patchsetLevelContainer {
-        width: 80ch;
-        border-radius: var(--border-radius);
-        box-shadow: var(--elevation-level-2);
-      }
-      .patchsetLevelContainer.resolved {
-        background-color: var(--comment-background-color);
-      }
-      .patchsetLevelContainer.unresolved {
-        background-color: var(--unresolved-comment-background-color);
-      }
-      .privateVisiblityInfo {
-        display: flex;
-        justify-content: center;
-        background-color: var(--info-background);
-        padding: var(--spacing-s) 0;
-      }
-      .privateVisiblityInfo gr-icon {
-        margin-right: var(--spacing-m);
-        color: var(--info-foreground);
-      }
-    `,
-  ];
+          line-height: 26px;
+        }
+        .attentionSummary gr-account-label,
+        .attention-detail gr-account-label {
+          --account-max-length: 120px;
+          display: inline-block;
+          padding: var(--spacing-xs) var(--spacing-m);
+          user-select: none;
+          --label-border-radius: 8px;
+        }
+        .attentionSummary gr-account-label {
+          margin: 0 var(--spacing-xs);
+          line-height: var(--line-height-normal);
+          vertical-align: top;
+        }
+        .attention-detail .peopleListValues {
+          line-height: calc(var(--line-height-normal) + 10px);
+        }
+        .attention-detail gr-account-label {
+          line-height: var(--line-height-normal);
+        }
+        .attentionSummary gr-account-label:focus,
+        .attention-detail gr-account-label:focus {
+          outline: none;
+        }
+        .attentionSummary gr-account-label:hover,
+        .attention-detail gr-account-label:hover {
+          box-shadow: var(--elevation-level-1);
+          cursor: pointer;
+        }
+        .attention-detail .attentionDetailsTitle {
+          display: flex;
+          justify-content: space-between;
+        }
+        .attention-detail .selectUsers {
+          color: var(--deemphasized-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        .attentionTip {
+          padding: var(--spacing-m);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin-top: var(--spacing-m);
+          background-color: var(--line-item-highlight-color);
+        }
+        .attentionTip div gr-icon {
+          margin-right: var(--spacing-s);
+        }
+        .patchsetLevelContainer {
+          width: 80ch;
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-2);
+        }
+        .patchsetLevelContainer.resolved {
+          background-color: var(--comment-background-color);
+        }
+        .patchsetLevelContainer.unresolved {
+          background-color: var(--unresolved-comment-background-color);
+        }
+        .privateVisiblityInfo {
+          display: flex;
+          justify-content: center;
+          background-color: var(--info-background);
+          padding: var(--spacing-s) 0;
+        }
+        .privateVisiblityInfo gr-icon {
+          margin-right: var(--spacing-m);
+          color: var(--info-foreground);
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 7723c66..02d5124 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -149,38 +149,40 @@
     );
   }
 
-  static override styles = [
-    sharedStyles,
-    modalStyles,
-    css`
-      .diffContainer {
-        padding: var(--spacing-l) 0;
-        border-bottom: 1px solid var(--border-color);
-      }
-      .file-name {
-        display: block;
-        padding: var(--spacing-s) var(--spacing-l);
-        background-color: var(--background-color-secondary);
-        border-bottom: 1px solid var(--border-color);
-      }
-      gr-button {
-        margin-left: var(--spacing-m);
-      }
-      .fix-picker {
-        display: flex;
-        align-items: center;
-        margin-right: var(--spacing-l);
-      }
-      .info {
-        background-color: var(--info-background);
-        padding: var(--spacing-l) var(--spacing-xl);
-      }
-      .info gr-icon {
-        color: var(--selected-foreground);
-        margin-right: var(--spacing-xl);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        .diffContainer {
+          padding: var(--spacing-l) 0;
+          border-bottom: 1px solid var(--border-color);
+        }
+        .file-name {
+          display: block;
+          padding: var(--spacing-s) var(--spacing-l);
+          background-color: var(--background-color-secondary);
+          border-bottom: 1px solid var(--border-color);
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        .fix-picker {
+          display: flex;
+          align-items: center;
+          margin-right: var(--spacing-l);
+        }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 33c8c22..ff46351 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -99,6 +99,7 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {keyed} from 'lit/directives/keyed.js';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -337,6 +338,12 @@
 
   private checksSubscription?: Subscription;
 
+  /**
+   * This key is used for the `keyed()` directive when rendering `gr-diff` and
+   * can thus be used to trigger re-construction of `gr-diff`.
+   */
+  private grDiffKey = 0;
+
   constructor() {
     super();
     this.syntaxLayer = new GrSyntaxLayerWorker(
@@ -493,30 +500,33 @@
       KnownExperimentId.NEW_IMAGE_DIFF_UI
     );
 
-    return html` <gr-diff
-      id="diff"
-      ?hidden=${this.hidden}
-      .noAutoRender=${this.noAutoRender}
-      .path=${this.path}
-      .prefs=${this.prefs}
-      .isImageDiff=${this.isImageDiff}
-      .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
-      .renderPrefs=${this.renderPrefs}
-      .lineWrapping=${this.lineWrapping}
-      .viewMode=${this.viewMode}
-      .lineOfInterest=${this.lineOfInterest}
-      .loggedIn=${this.loggedIn}
-      .errorMessage=${this.errorMessage}
-      .baseImage=${this.baseImage}
-      .revisionImage=${this.revisionImage}
-      .coverageRanges=${this.coverageRanges}
-      .blame=${this.blame}
-      .layers=${this.layers}
-      .diff=${this.diff}
-      .showNewlineWarningLeft=${showNewlineWarningLeft}
-      .showNewlineWarningRight=${showNewlineWarningRight}
-      .useNewImageDiffUi=${useNewImageDiffUi}
-    ></gr-diff>`;
+    return keyed(
+      this.grDiffKey,
+      html`<gr-diff
+        id="diff"
+        ?hidden=${this.hidden}
+        .noAutoRender=${this.noAutoRender}
+        .path=${this.path}
+        .prefs=${this.prefs}
+        .isImageDiff=${this.isImageDiff}
+        .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
+        .renderPrefs=${this.renderPrefs}
+        .lineWrapping=${this.lineWrapping}
+        .viewMode=${this.viewMode}
+        .lineOfInterest=${this.lineOfInterest}
+        .loggedIn=${this.loggedIn}
+        .errorMessage=${this.errorMessage}
+        .baseImage=${this.baseImage}
+        .revisionImage=${this.revisionImage}
+        .coverageRanges=${this.coverageRanges}
+        .blame=${this.blame}
+        .layers=${this.layers}
+        .diff=${this.diff}
+        .showNewlineWarningLeft=${showNewlineWarningLeft}
+        .showNewlineWarningRight=${showNewlineWarningRight}
+        .useNewImageDiffUi=${useNewImageDiffUi}
+      ></gr-diff>`
+    );
   }
 
   async initLayers() {
@@ -566,6 +576,7 @@
   async reloadInternal(shouldReportMetric?: boolean) {
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
+    this.grDiffKey++;
     // TODO: Find better names for these 3 clear/cancel methods. Ideally the
     // <gr-diff-host> should not re-used at all for another diff rendering pass.
     this.clear();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index e0d5f1d..c4f4d1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -60,6 +60,7 @@
   CommentsModel,
   commentsModelToken,
 } from '../../../models/comments/comments-model';
+import {isNewDiff} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 suite('gr-diff-host tests', () => {
   let element: GrDiffHost;
@@ -153,6 +154,7 @@
   });
 
   test('reload() cancels before network resolves', async () => {
+    if (isNewDiff()) return;
     assertIsDefined(element.diffElement);
     const cancelStub = sinon.stub(element.diffElement, 'cancel');
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 1d46841..d8c3abe 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -59,21 +59,23 @@
     super.disconnectedCallback();
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        /* Used to remove horizontal whitespace between the icons. */
-        display: flex;
-      }
-      gr-button.selected gr-icon {
-        color: var(--link-color);
-      }
-      gr-icon {
-        font-size: 1.3rem;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          /* Used to remove horizontal whitespace between the icons. */
+          display: flex;
+        }
+        gr-button.selected gr-icon {
+          color: var(--link-color);
+        }
+        gr-icon {
+          font-size: 1.3rem;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 6589ee8..ec46d8d 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -11,7 +11,6 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {AppContext, injectAppContext} from '../services/app-context';
 import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
@@ -39,7 +38,6 @@
     initClickReporter(reportingService);
     initInteractionReporter(reportingService);
   }
-  window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 89513e3..747f3eb 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -60,41 +60,43 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  static override styles = [
-    sharedStyles,
-    formStyles,
-    css`
-      gr-avatar {
-        height: 120px;
-        width: 120px;
-        margin-right: var(--spacing-xs);
-        vertical-align: -0.25em;
-      }
-      div section.hide {
-        display: none;
-      }
-      gr-hovercard-account-contents {
-        display: block;
-        max-width: 600px;
-        margin-top: var(--spacing-m);
-        background: var(--dialog-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-shadow: var(--elevation-level-5);
-      }
-      iron-autogrow-textarea {
-        background-color: var(--view-background-color);
-        color: var(--primary-text-color);
-      }
-      .lengthCounter {
-        font-weight: var(--font-weight-normal);
-      }
-      p {
-        max-width: 65ch;
-        margin-bottom: var(--spacing-m);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        gr-avatar {
+          height: 120px;
+          width: 120px;
+          margin-right: var(--spacing-xs);
+          vertical-align: -0.25em;
+        }
+        div section.hide {
+          display: none;
+        }
+        gr-hovercard-account-contents {
+          display: block;
+          max-width: 600px;
+          margin-top: var(--spacing-m);
+          background: var(--dialog-background-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-5);
+        }
+        iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
+        }
+        .lengthCounter {
+          font-weight: var(--font-weight-normal);
+        }
+        p {
+          max-width: 65ch;
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.account || this.loading) return nothing;
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 53548f8..568e7c3 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
@@ -36,28 +36,30 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  static override styles = [
-    sharedStyles,
-    formStyles,
-    css`
-      #changeCols {
-        width: auto;
-      }
-      #changeCols .visibleHeader {
-        text-align: center;
-      }
-      .checkboxContainer {
-        cursor: pointer;
-        text-align: center;
-      }
-      .checkboxContainer input {
-        cursor: pointer;
-      }
-      .checkboxContainer:hover {
-        outline: 1px solid var(--border-color);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        #changeCols {
+          width: auto;
+        }
+        #changeCols .visibleHeader {
+          text-align: center;
+        }
+        .checkboxContainer {
+          cursor: pointer;
+          text-align: center;
+        }
+        .checkboxContainer input {
+          cursor: pointer;
+        }
+        .checkboxContainer:hover {
+          outline: 1px solid var(--border-color);
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index b9f59bf..1c4fd30 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -29,35 +29,37 @@
 
   readonly restApiService = getAppContext().restApiService;
 
-  static override styles = [
-    sharedStyles,
-    formStyles,
-    css`
-      th {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-      }
-      #emailTable .emailColumn {
-        min-width: 32.5em;
-        width: auto;
-      }
-      #emailTable .preferredHeader {
-        text-align: center;
-        width: 6em;
-      }
-      #emailTable .preferredControl {
-        cursor: pointer;
-        height: auto;
-        text-align: center;
-      }
-      #emailTable .preferredControl .preferredRadio {
-        height: auto;
-      }
-      .preferredControl:hover {
-        outline: 1px solid var(--border-color);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        th {
+          color: var(--deemphasized-text-color);
+          text-align: left;
+        }
+        #emailTable .emailColumn {
+          min-width: 32.5em;
+          width: auto;
+        }
+        #emailTable .preferredHeader {
+          text-align: center;
+          width: 6em;
+        }
+        #emailTable .preferredControl {
+          cursor: pointer;
+          height: auto;
+          text-align: center;
+        }
+        #emailTable .preferredControl .preferredRadio {
+          height: auto;
+        }
+        .preferredControl:hover {
+          outline: 1px solid var(--border-color);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`<div class="gr-form-styles">
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index 32b32e2..da1f758 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -49,34 +49,36 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  static override styles = [
-    formStyles,
-    sharedStyles,
-    modalStyles,
-    css`
-      .keyHeader {
-        width: 9em;
-      }
-      .userIdHeader {
-        width: 15em;
-      }
-      #viewKeyModal {
-        padding: var(--spacing-xxl);
-        width: 50em;
-      }
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-      #existing {
-        margin-bottom: var(--spacing-l);
-      }
-      iron-autogrow-textarea {
-        background-color: var(--view-background-color);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        .keyHeader {
+          width: 9em;
+        }
+        .userIdHeader {
+          width: 15em;
+        }
+        #viewKeyModal {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        #existing {
+          margin-bottom: var(--spacing-l);
+        }
+        iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 7f67ea8..4c8e8da 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -36,37 +36,39 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  static override styles = [
-    sharedStyles,
-    formStyles,
-    modalStyles,
-    css`
-      tr th.emailAddressHeader,
-      tr th.identityHeader {
-        width: 15em;
-        padding: 0 10px;
-      }
-      tr td.statusColumn,
-      tr td.emailAddressColumn,
-      tr td.identityColumn {
-        word-break: break-word;
-      }
-      tr td.emailAddressColumn,
-      tr td.identityColumn {
-        padding: 4px 10px;
-        width: 15em;
-      }
-      .deleteButton {
-        float: right;
-      }
-      .deleteButton:not(.show) {
-        display: none;
-      }
-      .space {
-        margin-bottom: var(--spacing-l);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      modalStyles,
+      css`
+        tr th.emailAddressHeader,
+        tr th.identityHeader {
+          width: 15em;
+          padding: 0 10px;
+        }
+        tr td.statusColumn,
+        tr td.emailAddressColumn,
+        tr td.identityColumn {
+          word-break: break-word;
+        }
+        tr td.emailAddressColumn,
+        tr td.identityColumn {
+          padding: 4px 10px;
+          width: 15em;
+        }
+        .deleteButton {
+          float: right;
+        }
+        .deleteButton:not(.show) {
+          display: none;
+        }
+        .space {
+          margin-bottom: var(--spacing-l);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`<div class="gr-form-styles">
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 9c23857..b00529a 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -48,31 +48,33 @@
     );
   }
 
-  static override styles = [
-    formStyles,
-    sharedStyles,
-    fontStyles,
-    menuPageStyles,
-    css`
-      .buttonColumn {
-        width: 2em;
-      }
-      .moveUpButton,
-      .moveDownButton {
-        width: 100%;
-      }
-      tbody tr:first-of-type td .moveUpButton,
-      tbody tr:last-of-type td .moveDownButton {
-        display: none;
-      }
-      td.urlCell {
-        word-break: break-word;
-      }
-      .newUrlInput {
-        min-width: 23em;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      fontStyles,
+      menuPageStyles,
+      css`
+        .buttonColumn {
+          width: 2em;
+        }
+        .moveUpButton,
+        .moveDownButton {
+          width: 100%;
+        }
+        tbody tr:first-of-type td .moveUpButton,
+        tbody tr:last-of-type td .moveDownButton {
+          display: none;
+        }
+        td.urlCell {
+          word-break: break-word;
+        }
+        .newUrlInput {
+          min-width: 23em;
+        }
+      `,
+    ];
+  }
 
   override render() {
     const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index c6c023e..435eb44 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -68,50 +68,52 @@
     }
   }
 
-  static override styles = [
-    sharedStyles,
-    formStyles,
-    css`
-      :host {
-        display: block;
-      }
-      main {
-        max-width: 46em;
-      }
-      :host(.loading) main {
-        display: none;
-      }
-      .loadingMessage {
-        display: none;
-        font-style: italic;
-      }
-      :host(.loading) .loadingMessage {
-        display: block;
-      }
-      hr {
-        margin-top: var(--spacing-l);
-        margin-bottom: var(--spacing-l);
-      }
-      header {
-        border-bottom: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-        margin-bottom: var(--spacing-l);
-      }
-      .container {
-        padding: var(--spacing-m) var(--spacing-xl);
-      }
-      footer {
-        display: flex;
-        justify-content: flex-end;
-      }
-      footer gr-button {
-        margin-left: var(--spacing-l);
-      }
-      input {
-        width: 20em;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        :host {
+          display: block;
+        }
+        main {
+          max-width: 46em;
+        }
+        :host(.loading) main {
+          display: none;
+        }
+        .loadingMessage {
+          display: none;
+          font-style: italic;
+        }
+        :host(.loading) .loadingMessage {
+          display: block;
+        }
+        hr {
+          margin-top: var(--spacing-l);
+          margin-bottom: var(--spacing-l);
+        }
+        header {
+          border-bottom: 1px solid var(--border-color);
+          font-weight: var(--font-weight-bold);
+          margin-bottom: var(--spacing-l);
+        }
+        .container {
+          padding: var(--spacing-m) var(--spacing-xl);
+        }
+        footer {
+          display: flex;
+          justify-content: flex-end;
+        }
+        footer gr-button {
+          margin-left: var(--spacing-l);
+        }
+        input {
+          width: 20em;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`<div class="container gr-form-styles">
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 24a18c9..29203d0 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
@@ -303,43 +303,45 @@
     });
   }
 
-  static override styles = [
-    sharedStyles,
-    paperStyles,
-    fontStyles,
-    formStyles,
-    menuPageStyles,
-    pageNavStyles,
-    css`
-      :host {
-        color: var(--primary-text-color);
-      }
-      h2 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-      }
-      .newEmailInput {
-        width: 20em;
-      }
-      #email {
-        margin-bottom: var(--spacing-l);
-      }
-      .filters p {
-        margin-bottom: var(--spacing-l);
-      }
-      .queryExample em {
-        color: violet;
-      }
-      .toggle {
-        align-items: center;
-        display: flex;
-        margin-bottom: var(--spacing-l);
-        margin-right: var(--spacing-l);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      fontStyles,
+      formStyles,
+      menuPageStyles,
+      pageNavStyles,
+      css`
+        :host {
+          color: var(--primary-text-color);
+        }
+        h2 {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h2);
+          font-weight: var(--font-weight-h2);
+          line-height: var(--line-height-h2);
+        }
+        .newEmailInput {
+          width: 20em;
+        }
+        #email {
+          margin-bottom: var(--spacing-l);
+        }
+        .filters p {
+          margin-bottom: var(--spacing-l);
+        }
+        .queryExample em {
+          color: violet;
+        }
+        .toggle {
+          align-items: center;
+          display: flex;
+          margin-bottom: var(--spacing-l);
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
 
   override render() {
     const isLoading = this.loading || this.loading === undefined;
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 07209db..9045dd6 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
@@ -158,32 +158,34 @@
     );
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      gr-account-chip {
-        display: inline-block;
-        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-      }
-      gr-account-entry {
-        display: flex;
-        flex: 1;
-        min-width: 10em;
-        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-      }
-      .group {
-        --account-label-suffix: ' (group)';
-      }
-      .newlyAdded {
-        font-style: italic;
-      }
-      .list {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        gr-account-chip {
+          display: inline-block;
+          margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+        }
+        gr-account-entry {
+          display: flex;
+          flex: 1;
+          min-width: 10em;
+          margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+        }
+        .group {
+          --account-label-suffix: ' (group)';
+        }
+        .newlyAdded {
+          font-style: italic;
+        }
+        .list {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`<div class="list">
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index fd1311c..210b91c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -201,59 +201,63 @@
       .inputElement as HTMLInputElement;
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      paper-input.borderless {
-        border: none;
-        padding: 0;
-      }
-      paper-input {
-        background-color: var(--view-background-color);
-        color: var(--primary-text-color);
-        border: 1px solid var(--prominent-border-color, var(--border-color));
-        border-radius: var(--border-radius);
-        padding: var(--spacing-s);
-        --paper-input-container_-_padding: 0;
-        --paper-input-container-input_-_font-size: var(--font-size-normal);
-        --paper-input-container-input_-_line-height: var(--line-height-normal);
-        /* This is a hack for not being able to set height:0 on the underline
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        paper-input.borderless {
+          border: none;
+          padding: 0;
+        }
+        paper-input {
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
+          border: 1px solid var(--prominent-border-color, var(--border-color));
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s);
+          --paper-input-container_-_padding: 0;
+          --paper-input-container-input_-_font-size: var(--font-size-normal);
+          --paper-input-container-input_-_line-height: var(
+            --line-height-normal
+          );
+          /* This is a hack for not being able to set height:0 on the underline
             of a paper-input 2.2.3 element. All the underline fixes below only
             actually work in 3.x.x, so the height must be adjusted directly as
             a workaround until we are on Polymer 3. */
-        height: var(--line-height-normal);
-        --paper-input-container-underline-height: 0;
-        --paper-input-container-underline-wrapper-height: 0;
-        --paper-input-container-underline-focus-height: 0;
-        --paper-input-container-underline-legacy-height: 0;
-        --paper-input-container-underline_-_height: 0;
-        --paper-input-container-underline_-_display: none;
-        --paper-input-container-underline-focus_-_height: 0;
-        --paper-input-container-underline-focus_-_display: none;
-        --paper-input-container-underline-disabled_-_height: 0;
-        --paper-input-container-underline-disabled_-_display: none;
-        /* Hide label for input. The label is still visible for
+          height: var(--line-height-normal);
+          --paper-input-container-underline-height: 0;
+          --paper-input-container-underline-wrapper-height: 0;
+          --paper-input-container-underline-focus-height: 0;
+          --paper-input-container-underline-legacy-height: 0;
+          --paper-input-container-underline_-_height: 0;
+          --paper-input-container-underline_-_display: none;
+          --paper-input-container-underline-focus_-_height: 0;
+          --paper-input-container-underline-focus_-_display: none;
+          --paper-input-container-underline-disabled_-_height: 0;
+          --paper-input-container-underline-disabled_-_display: none;
+          /* Hide label for input. The label is still visible for
            screen readers. Workaround found at:
            https://github.com/PolymerElements/paper-input/issues/478 */
-        --paper-input-container-label_-_display: none;
-      }
-      paper-input.showBlueFocusBorder:focus {
-        border: 2px solid var(--input-focus-border-color);
-        /*
+          --paper-input-container-label_-_display: none;
+        }
+        paper-input.showBlueFocusBorder:focus {
+          border: 2px solid var(--input-focus-border-color);
+          /*
          * The goal is to have a thicker blue border when focused and a thinner
          * gray border when blurred. To avoid shifting neighboring elements
          * around when the border size changes, a negative margin is added to
          * compensate. box-sizing: border-box; will not work since there is
          * important padding to add around the content.
          */
-        margin: -1px;
-      }
-      paper-input.warnUncommitted {
-        --paper-input-container-input_-_color: var(--error-text-color);
-        --paper-input-container-input_-_font-size: inherit;
-      }
-    `,
-  ];
+          margin: -1px;
+        }
+        paper-input.warnUncommitted {
+          --paper-input-container-input_-_color: var(--error-text-color);
+          --paper-input-container-input_-_font-size: inherit;
+        }
+      `,
+    ];
+  }
 
   override connectedCallback() {
     super.connectedCallback();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index e880531..405409d 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -56,67 +56,70 @@
    * Note: Do not use sharedStyles or other styles here that should not affect
    * the generated HTML of the markdown.
    */
-  static override styles = [
-    css`
-      a {
-        color: var(--link-color);
-      }
-      p,
-      ul,
-      code,
-      blockquote {
-        margin: 0 0 var(--spacing-m) 0;
-        max-width: var(--gr-formatted-text-prose-max-width, none);
-      }
-      p:last-child,
-      ul:last-child,
-      blockquote:last-child,
-      pre:last-child {
-        margin: 0;
-      }
-      blockquote {
-        border-left: var(--spacing-xxs) solid var(--comment-quote-marker-color);
-        padding: 0 var(--spacing-m);
-      }
-      code {
-        background-color: var(--background-color-secondary);
-        border: var(--spacing-xxs) solid var(--border-color);
-        display: block;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-mono);
-        margin: var(--spacing-m) 0;
-        padding: var(--spacing-xxs) var(--spacing-s);
-        overflow-x: auto;
-        /* Pre will preserve whitespace and line breaks but not wrap */
-        white-space: pre;
-      }
-      /* Non-multiline code elements need display:inline to shrink and not take
+  static override get styles() {
+    return [
+      css`
+        a {
+          color: var(--link-color);
+        }
+        p,
+        ul,
+        code,
+        blockquote {
+          margin: 0 0 var(--spacing-m) 0;
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+        }
+        p:last-child,
+        ul:last-child,
+        blockquote:last-child,
+        pre:last-child {
+          margin: 0;
+        }
+        blockquote {
+          border-left: var(--spacing-xxs) solid
+            var(--comment-quote-marker-color);
+          padding: 0 var(--spacing-m);
+        }
+        code {
+          background-color: var(--background-color-secondary);
+          border: var(--spacing-xxs) solid var(--border-color);
+          display: block;
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          line-height: var(--line-height-mono);
+          margin: var(--spacing-m) 0;
+          padding: var(--spacing-xxs) var(--spacing-s);
+          overflow-x: auto;
+          /* Pre will preserve whitespace and line breaks but not wrap */
+          white-space: pre;
+        }
+        /* Non-multiline code elements need display:inline to shrink and not take
          a whole row */
-      :not(pre) > code {
-        display: inline;
-      }
-      li {
-        margin-left: var(--spacing-xl);
-      }
-      gr-account-chip {
-        display: inline;
-      }
-      .plaintext {
-        font: inherit;
-        white-space: var(--linked-text-white-space, pre-wrap);
-        word-wrap: var(--linked-text-word-wrap, break-word);
-      }
-      .markdown-html {
-        /* code overrides white-space to pre, everything else should wrap as
+        :not(pre) > code {
+          display: inline;
+        }
+        li {
+          margin-left: var(--spacing-xl);
+        }
+        gr-account-chip {
+          display: inline;
+        }
+        .plaintext {
+          font: inherit;
+          white-space: var(--linked-text-white-space, pre-wrap);
+          word-wrap: var(--linked-text-word-wrap, break-word);
+        }
+        .markdown-html {
+          /* code overrides white-space to pre, everything else should wrap as
            normal. */
-        white-space: normal;
-        /* prose will automatically wrap but inline <code> blocks won't and we
+          white-space: normal;
+          /* prose will automatically wrap but inline <code> blocks won't and we
            should overflow in that case rather than wrapping or leaking out */
-        overflow-x: auto;
-      }
-    `,
-  ];
+          overflow-x: auto;
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 95f1b8a..c29417e 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -175,53 +175,55 @@
     }
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: flex;
-        position: relative;
-      }
-      :host(.monospace) {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        font-weight: var(--font-weight-normal);
-      }
-      :host(.code) {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        /* usually 16px = 12px + 4px */
-        line-height: calc(var(--font-size-code) + var(--spacing-s));
-        font-weight: var(--font-weight-normal);
-      }
-      #emojiSuggestions {
-        font-family: var(--font-family);
-      }
-      #textarea {
-        background-color: var(--view-background-color);
-        width: 100%;
-      }
-      #hiddenText #emojiSuggestions {
-        visibility: visible;
-        white-space: normal;
-      }
-      iron-autogrow-textarea {
-        position: relative;
-      }
-      #textarea.noBorder {
-        border: none;
-      }
-      #hiddenText {
-        display: block;
-        float: left;
-        position: absolute;
-        visibility: hidden;
-        width: 100%;
-        white-space: pre-wrap;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: flex;
+          position: relative;
+        }
+        :host(.monospace) {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          font-weight: var(--font-weight-normal);
+        }
+        :host(.code) {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          /* usually 16px = 12px + 4px */
+          line-height: calc(var(--font-size-code) + var(--spacing-s));
+          font-weight: var(--font-weight-normal);
+        }
+        #emojiSuggestions {
+          font-family: var(--font-family);
+        }
+        #textarea {
+          background-color: var(--view-background-color);
+          width: 100%;
+        }
+        #hiddenText #emojiSuggestions {
+          visibility: visible;
+          white-space: normal;
+        }
+        iron-autogrow-textarea {
+          position: relative;
+        }
+        #textarea.noBorder {
+          border: none;
+        }
+        #hiddenText {
+          display: block;
+          float: left;
+          position: absolute;
+          visibility: hidden;
+          width: 100%;
+          white-space: pre-wrap;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
index ff068a7..ea67661 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -25,42 +25,44 @@
 export class GrUserSuggetionFix extends LitElement {
   private readonly flagsService = getAppContext().flagsService;
 
-  static override styles = [
-    css`
-      .header {
-        background-color: var(--background-color-primary);
-        border: 1px solid var(--border-color);
-        padding: var(--spacing-xs) var(--spacing-xl);
-        display: flex;
-        align-items: center;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-      }
-      .header .title {
-        flex: 1;
-      }
-      .copyButton {
-        margin-right: var(--spacing-l);
-      }
-      code {
-        max-width: var(--gr-formatted-text-prose-max-width, none);
-        background-color: var(--background-color-secondary);
-        border: 1px solid var(--border-color);
-        border-top: 0;
-        display: block;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-mono);
-        margin-bottom: var(--spacing-m);
-        padding: var(--spacing-xxs) var(--spacing-s);
-        overflow-x: auto;
-        /* Pre will preserve whitespace and line breaks but not wrap */
-        white-space: pre;
-        border-bottom-left-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      css`
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        .header .title {
+          flex: 1;
+        }
+        .copyButton {
+          margin-right: var(--spacing-l);
+        }
+        code {
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+          background-color: var(--background-color-secondary);
+          border: 1px solid var(--border-color);
+          border-top: 0;
+          display: block;
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          line-height: var(--line-height-mono);
+          margin-bottom: var(--spacing-m);
+          padding: var(--spacing-xxs) var(--spacing-s);
+          overflow-x: auto;
+          /* Pre will preserve whitespace and line breaks but not wrap */
+          white-space: pre;
+          border-bottom-left-radius: var(--border-radius);
+          border-bottom-right-radius: var(--border-radius);
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
diff --git a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts
index 5695f4d..e4afd23 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts
@@ -99,112 +99,123 @@
     linesToExpand: number;
   }>();
 
-  static override styles = css`
-    :host {
-      display: flex;
-      justify-content: center;
-      flex-direction: column;
-      position: relative;
-    }
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: flex;
+          justify-content: center;
+          flex-direction: column;
+          position: relative;
+        }
 
-    :host([showConfig='above']) {
-      justify-content: flex-end;
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: var(--gr-context-controls-margin-bottom);
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-      .horizontalFlex {
-        align-items: end;
-      }
-    }
+        :host([showConfig='above']) {
+          justify-content: flex-end;
+          margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+          margin-bottom: var(--gr-context-controls-margin-bottom);
+          height: calc(var(--line-height-normal) + var(--spacing-s));
+          .horizontalFlex {
+            align-items: end;
+          }
+        }
 
-    :host([showConfig='below']) {
-      justify-content: flex-start;
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      .horizontalFlex {
-        align-items: start;
-      }
-    }
+        :host([showConfig='below']) {
+          justify-content: flex-start;
+          margin-top: 1px;
+          margin-bottom: calc(
+            0px - var(--line-height-normal) - var(--spacing-s)
+          );
+          .horizontalFlex {
+            align-items: start;
+          }
+        }
 
-    :host([showConfig='both']) {
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height)
-      );
-      .horizontalFlex {
-        align-items: center;
-      }
-    }
+        :host([showConfig='both']) {
+          margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+          margin-bottom: calc(
+            0px - var(--line-height-normal) - var(--spacing-s)
+          );
+          height: calc(
+            2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+              var(--divider-height)
+          );
+          .horizontalFlex {
+            align-items: center;
+          }
+        }
 
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
+        .contextControlButton {
+          background-color: var(--default-button-background-color);
+          font: var(--context-control-button-font, inherit);
+        }
 
-    paper-button {
-      text-transform: none;
-      align-items: center;
-      background-color: var(--background-color);
-      font-family: inherit;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      color: var(--diff-context-control-color);
-      border: solid var(--border-color);
-      border-width: 1px;
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s) var(--spacing-l);
-    }
+        paper-button {
+          text-transform: none;
+          align-items: center;
+          background-color: var(--background-color);
+          font-family: inherit;
+          margin: var(--margin, 0);
+          min-width: var(--border, 0);
+          color: var(--diff-context-control-color);
+          border: solid var(--border-color);
+          border-width: 1px;
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s) var(--spacing-l);
+        }
 
-    paper-button:hover {
-      /* same as defined in gr-button */
-      background: rgba(0, 0, 0, 0.12);
-    }
-    paper-button:focus-visible {
-      /* paper-button sets this to 0, thus preventing focus-based styling. */
-      outline-width: 1px;
-    }
+        paper-button:hover {
+          /* same as defined in gr-button */
+          background: rgba(0, 0, 0, 0.12);
+        }
+        paper-button:focus-visible {
+          /* paper-button sets this to 0, thus preventing focus-based styling. */
+          outline-width: 1px;
+        }
 
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-      position: relative;
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-      /* Places a default background layer behind the "all button" that can have opacity */
-      background-color: var(--default-button-background-color);
-    }
+        .aboveBelowButtons {
+          display: flex;
+          flex-direction: column;
+          justify-content: center;
+          margin-left: var(--spacing-m);
+          position: relative;
+        }
+        .aboveBelowButtons:first-child {
+          margin-left: 0;
+          /* Places a default background layer behind the "all button" that can have opacity */
+          background-color: var(--default-button-background-color);
+        }
 
-    .horizontalFlex {
-      display: flex;
-      justify-content: center;
-      align-items: var(--gr-context-controls-horizontal-align-items, center);
-    }
+        .horizontalFlex {
+          display: flex;
+          justify-content: center;
+          align-items: var(
+            --gr-context-controls-horizontal-align-items,
+            center
+          );
+        }
 
-    .aboveButton {
-      border-bottom-width: 0;
-      border-bottom-right-radius: 0;
-      border-bottom-left-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-    }
-    .belowButton {
-      border-top-width: 0;
-      border-top-left-radius: 0;
-      border-top-right-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
-    }
-    .belowButton:first-child {
-      margin-top: 0;
-    }
-    .breadcrumbTooltip {
-      white-space: nowrap;
-    }
-  `;
+        .aboveButton {
+          border-bottom-width: 0;
+          border-bottom-right-radius: 0;
+          border-bottom-left-radius: 0;
+          padding: var(--spacing-xxs) var(--spacing-l);
+        }
+        .belowButton {
+          border-top-width: 0;
+          border-top-left-radius: 0;
+          border-top-right-radius: 0;
+          padding: var(--spacing-xxs) var(--spacing-l);
+          margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+        }
+        .belowButton:first-child {
+          margin-top: 0;
+        }
+        .breadcrumbTooltip {
+          white-space: nowrap;
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts
index 38bd707..5669bcf 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts
@@ -19,7 +19,7 @@
    */
   getLength(node: Node) {
     if (node instanceof Comment) return 0;
-    return this.getStringLength(node.textContent || '');
+    return GrAnnotation.getStringLength(node.textContent || '');
   },
 
   /**
@@ -65,7 +65,7 @@
 
     const nestedNodes: Node[] = [];
     for (let node of childNodes) {
-      const initialNodeLength = this.getLength(node);
+      const initialNodeLength = GrAnnotation.getLength(node);
       // If the current node is completely before the offset.
       if (offset > 0 && initialNodeLength <= offset) {
         offset -= initialNodeLength;
@@ -73,15 +73,15 @@
       }
 
       if (offset > 0) {
-        node = this.splitNode(node, offset);
+        node = GrAnnotation.splitNode(node, offset);
         offset = 0;
       }
-      if (this.getLength(node) > length) {
-        this.splitNode(node, length);
+      if (GrAnnotation.getLength(node) > length) {
+        GrAnnotation.splitNode(node, length);
       }
       nestedNodes.push(node);
 
-      length -= this.getLength(node);
+      length -= GrAnnotation.getLength(node);
       if (!length) break;
     }
 
@@ -116,7 +116,7 @@
     let subLength;
 
     for (const node of nodes) {
-      nodeLength = this.getLength(node);
+      nodeLength = GrAnnotation.getLength(node);
 
       // If the current node is completely before the offset.
       if (nodeLength <= offset) {
@@ -128,9 +128,9 @@
       subLength = Math.min(length, nodeLength - offset);
 
       if (node instanceof Text) {
-        this._annotateText(node, offset, subLength, cssClass);
+        GrAnnotation._annotateText(node, offset, subLength, cssClass);
       } else if (node instanceof Element) {
-        this.annotateElement(node, offset, subLength, cssClass);
+        GrAnnotation.annotateElement(node, offset, subLength, cssClass);
       }
 
       // If there is still more to annotate, then shift the indices, otherwise
@@ -172,19 +172,19 @@
     firstPart?: boolean
   ) {
     if (
-      (this.getLength(node) === offset && firstPart) ||
+      (GrAnnotation.getLength(node) === offset && firstPart) ||
       (offset === 0 && !firstPart)
     ) {
-      return this.wrapInHighlight(node, cssClass);
+      return GrAnnotation.wrapInHighlight(node, cssClass);
     }
     if (firstPart) {
-      this.splitNode(node, offset);
+      GrAnnotation.splitNode(node, offset);
       // Node points to first part of the Text, second one is sibling.
     } else {
       // if node is Text then splitNode will return a Text
-      node = this.splitNode(node, offset) as Text;
+      node = GrAnnotation.splitNode(node, offset) as Text;
     }
-    return this.wrapInHighlight(node, cssClass);
+    return GrAnnotation.wrapInHighlight(node, cssClass);
   },
 
   /**
@@ -193,7 +193,7 @@
    */
   splitNode(element: Node, offset: number) {
     if (element instanceof Text) {
-      return this.splitTextNode(element, offset);
+      return GrAnnotation.splitTextNode(element, offset);
     }
     const tail = element.cloneNode(false);
 
@@ -203,13 +203,14 @@
     let node = element.firstChild;
     while (
       node &&
-      (this.getLength(node) <= offset || this.getLength(node) === 0)
+      (GrAnnotation.getLength(node) <= offset ||
+        GrAnnotation.getLength(node) === 0)
     ) {
-      offset -= this.getLength(node);
+      offset -= GrAnnotation.getLength(node);
       node = node.nextSibling;
     }
-    if (node && this.getLength(node) > offset) {
-      tail.appendChild(this.splitNode(node, offset));
+    if (node && GrAnnotation.getLength(node) > offset) {
+      tail.appendChild(GrAnnotation.splitNode(node, offset));
     }
     while (node && node.nextSibling) {
       tail.appendChild(node.nextSibling);
@@ -245,7 +246,7 @@
   },
 
   _annotateText(node: Text, offset: number, length: number, cssClass: string) {
-    const nodeLength = this.getLength(node);
+    const nodeLength = GrAnnotation.getLength(node);
 
     // There are four cases:
     //  1) Entire node is highlighted.
@@ -255,17 +256,17 @@
 
     if (offset === 0 && nodeLength === length) {
       // Case 1.
-      this.wrapInHighlight(node, cssClass);
+      GrAnnotation.wrapInHighlight(node, cssClass);
     } else if (offset === 0) {
       // Case 2.
-      this.splitAndWrapInHighlight(node, length, cssClass, true);
+      GrAnnotation.splitAndWrapInHighlight(node, length, cssClass, true);
     } else if (offset + length === nodeLength) {
       // Case 3
-      this.splitAndWrapInHighlight(node, offset, cssClass, false);
+      GrAnnotation.splitAndWrapInHighlight(node, offset, cssClass, false);
     } else {
       // Case 4
-      this.splitAndWrapInHighlight(
-        this.splitTextNode(node, offset),
+      GrAnnotation.splitAndWrapInHighlight(
+        GrAnnotation.splitTextNode(node, offset),
         length,
         cssClass,
         true
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
index 2aa8096..0da3522 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
@@ -374,6 +374,7 @@
 
   private renderContainer() {
     const cssClasses = {
+      oldDiff: true,
       diffContainer: true,
       unified: this.viewMode === DiffViewMode.UNIFIED,
       sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts
index 645a64a..af3a492 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts
@@ -61,7 +61,7 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer sideBySide">
+          <div class="diffContainer oldDiff sideBySide">
             <table id="diffTable"></table>
           </div>
         `
@@ -77,7 +77,7 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer unified">
+          <div class="diffContainer oldDiff unified">
             <table class="selected-right" id="diffTable">
               <colgroup>
                 <col class="blame gr-diff" />
@@ -1343,7 +1343,7 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer sideBySide">
+          <div class="diffContainer oldDiff sideBySide">
             <table class="selected-right" id="diffTable">
               <colgroup>
                 <col class="blame gr-diff" />
@@ -3141,7 +3141,7 @@
         assert.shadowDom.equal(
           element,
           /* HTML */ `
-            <div class="diffContainer sideBySide">
+            <div class="diffContainer oldDiff sideBySide">
               <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
               <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
               <table class="selected-right" id="diffTable">
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
index 36e1f1a..e7ae8a4 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -12,6 +12,9 @@
 import {getShowConfig} from './gr-context-controls';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {when} from 'lit/directives/when.js';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
 
 export class GrContextControlsSection extends LitElement {
   /** Should context controls be rendered for expanding above the section? */
@@ -38,6 +41,19 @@
   @state()
   addTableWrapperForTesting = false;
 
+  @state() viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getDiffModel().viewMode$,
+      viewMode => (this.viewMode = viewMode)
+    );
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -82,7 +98,7 @@
   }
 
   private isSideBySide() {
-    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+    return this.viewMode !== DiffViewMode.UNIFIED;
   }
 
   private createContextControlRow() {
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
index 6a557fc..d936126 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -3,7 +3,12 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {
+  DIProviderElement,
+  wrapInProvider,
+} from '../../../models/di-provider-element';
 import '../../../test/common-test-setup';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import './gr-context-controls-section';
 import {GrContextControlsSection} from './gr-context-controls-section';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -12,9 +17,17 @@
   let element: GrContextControlsSection;
 
   setup(async () => {
-    element = await fixture<GrContextControlsSection>(
-      html`<gr-context-controls-section></gr-context-controls-section>`
-    );
+    const diffModel = new DiffModel();
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-context-controls-section></gr-context-controls-section>`,
+          diffModelToken,
+          diffModel
+        )
+      )
+    ).querySelector<GrContextControlsSection>('gr-context-controls-section')!;
+
     element.addTableWrapperForTesting = true;
     await element.updateComplete;
   });
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 43c8113..b2c0fcb 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
@@ -99,112 +99,123 @@
     linesToExpand: number;
   }>();
 
-  static override styles = css`
-    :host {
-      display: flex;
-      justify-content: center;
-      flex-direction: column;
-      position: relative;
-    }
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: flex;
+          justify-content: center;
+          flex-direction: column;
+          position: relative;
+        }
 
-    :host([showConfig='above']) {
-      justify-content: flex-end;
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: var(--gr-context-controls-margin-bottom);
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-      .horizontalFlex {
-        align-items: end;
-      }
-    }
+        :host([showConfig='above']) {
+          justify-content: flex-end;
+          margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+          margin-bottom: var(--gr-context-controls-margin-bottom);
+          height: calc(var(--line-height-normal) + var(--spacing-s));
+          .horizontalFlex {
+            align-items: end;
+          }
+        }
 
-    :host([showConfig='below']) {
-      justify-content: flex-start;
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      .horizontalFlex {
-        align-items: start;
-      }
-    }
+        :host([showConfig='below']) {
+          justify-content: flex-start;
+          margin-top: 1px;
+          margin-bottom: calc(
+            0px - var(--line-height-normal) - var(--spacing-s)
+          );
+          .horizontalFlex {
+            align-items: start;
+          }
+        }
 
-    :host([showConfig='both']) {
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height)
-      );
-      .horizontalFlex {
-        align-items: center;
-      }
-    }
+        :host([showConfig='both']) {
+          margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+          margin-bottom: calc(
+            0px - var(--line-height-normal) - var(--spacing-s)
+          );
+          height: calc(
+            2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+              var(--divider-height)
+          );
+          .horizontalFlex {
+            align-items: center;
+          }
+        }
 
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
+        .contextControlButton {
+          background-color: var(--default-button-background-color);
+          font: var(--context-control-button-font, inherit);
+        }
 
-    paper-button {
-      text-transform: none;
-      align-items: center;
-      background-color: var(--background-color);
-      font-family: inherit;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      color: var(--diff-context-control-color);
-      border: solid var(--border-color);
-      border-width: 1px;
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s) var(--spacing-l);
-    }
+        paper-button {
+          text-transform: none;
+          align-items: center;
+          background-color: var(--background-color);
+          font-family: inherit;
+          margin: var(--margin, 0);
+          min-width: var(--border, 0);
+          color: var(--diff-context-control-color);
+          border: solid var(--border-color);
+          border-width: 1px;
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s) var(--spacing-l);
+        }
 
-    paper-button:hover {
-      /* same as defined in gr-button */
-      background: rgba(0, 0, 0, 0.12);
-    }
-    paper-button:focus-visible {
-      /* paper-button sets this to 0, thus preventing focus-based styling. */
-      outline-width: 1px;
-    }
+        paper-button:hover {
+          /* same as defined in gr-button */
+          background: rgba(0, 0, 0, 0.12);
+        }
+        paper-button:focus-visible {
+          /* paper-button sets this to 0, thus preventing focus-based styling. */
+          outline-width: 1px;
+        }
 
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-      position: relative;
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-      /* Places a default background layer behind the "all button" that can have opacity */
-      background-color: var(--default-button-background-color);
-    }
+        .aboveBelowButtons {
+          display: flex;
+          flex-direction: column;
+          justify-content: center;
+          margin-left: var(--spacing-m);
+          position: relative;
+        }
+        .aboveBelowButtons:first-child {
+          margin-left: 0;
+          /* Places a default background layer behind the "all button" that can have opacity */
+          background-color: var(--default-button-background-color);
+        }
 
-    .horizontalFlex {
-      display: flex;
-      justify-content: center;
-      align-items: var(--gr-context-controls-horizontal-align-items, center);
-    }
+        .horizontalFlex {
+          display: flex;
+          justify-content: center;
+          align-items: var(
+            --gr-context-controls-horizontal-align-items,
+            center
+          );
+        }
 
-    .aboveButton {
-      border-bottom-width: 0;
-      border-bottom-right-radius: 0;
-      border-bottom-left-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-    }
-    .belowButton {
-      border-top-width: 0;
-      border-top-left-radius: 0;
-      border-top-right-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
-    }
-    .belowButton:first-child {
-      margin-top: 0;
-    }
-    .breadcrumbTooltip {
-      white-space: nowrap;
-    }
-  `;
+        .aboveButton {
+          border-bottom-width: 0;
+          border-bottom-right-radius: 0;
+          border-bottom-left-radius: 0;
+          padding: var(--spacing-xxs) var(--spacing-l);
+        }
+        .belowButton {
+          border-top-width: 0;
+          border-top-left-radius: 0;
+          border-top-right-radius: 0;
+          padding: var(--spacing-xxs) var(--spacing-l);
+          margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+        }
+        .belowButton:first-child {
+          margin-top: 0;
+        }
+        .breadcrumbTooltip {
+          white-space: nowrap;
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
deleted file mode 100644
index 7ace605..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffBuilder} from './gr-diff-builder';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {html, render} from 'lit';
-import {FILE} from '../../../api/diff';
-
-export class GrDiffBuilderBinary extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement
-  ) {
-    super(diff, prefs, outputEl);
-  }
-
-  override buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const section = createElementDiff('tbody', 'binary-diff');
-    // Do not create a diff row for LOST.
-    if (group.lines[0].beforeNumber !== FILE) return section;
-    return super.buildSectionElement(group);
-  }
-
-  public renderBinaryDiff() {
-    render(
-      html`
-        <tbody class="gr-diff binary-diff">
-          <tr class="gr-diff">
-            <td colspan="5" class="gr-diff">
-              <span>Difference in binary files</span>
-            </td>
-          </tr>
-        </tbody>
-      `,
-      this.outputEl
-    );
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index eeb07d8..fd3b975 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -4,83 +4,16 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {ImageInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {FILE, RenderPreferences, Side} from '../../../api/diff';
+import {Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
 import {html, LitElement, nothing} from 'lit';
 import {property, query, state} from 'lit/decorators.js';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {isNewDiff, createElementDiff} from '../gr-diff/gr-diff-utils';
+import {isNewDiff} from '../gr-diff/gr-diff-utils';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
 const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-export class GrDiffBuilderImage extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    private readonly baseImage: ImageInfo | null,
-    private readonly revisionImage: ImageInfo | null,
-    renderPrefs?: RenderPreferences,
-    private readonly useNewImageDiffUi: boolean = false
-  ) {
-    super(diff, prefs, outputEl, [], renderPrefs);
-  }
-
-  override buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const section = createElementDiff('tbody');
-    // Do not create a diff row for LOST.
-    if (group.lines[0].beforeNumber !== FILE) return section;
-    return super.buildSectionElement(group);
-  }
-
-  public renderImageDiff() {
-    const imageDiff = this.useNewImageDiffUi
-      ? this.createImageDiffNew()
-      : this.createImageDiffOld();
-    this.outputEl.appendChild(imageDiff);
-  }
-
-  private createImageDiffNew() {
-    // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
-    const imageDiff = document.createElement(
-      'gr-diff-image-new'
-    ) as GrDiffImageNew;
-    imageDiff.automaticBlink = this.autoBlink();
-    imageDiff.baseImage = this.baseImage ?? undefined;
-    imageDiff.revisionImage = this.revisionImage ?? undefined;
-    return imageDiff;
-  }
-
-  private createImageDiffOld() {
-    // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
-    const imageDiff = document.createElement(
-      'gr-diff-image-old'
-    ) as GrDiffImageOld;
-    imageDiff.baseImage = this.baseImage ?? undefined;
-    imageDiff.revisionImage = this.revisionImage ?? undefined;
-    return imageDiff;
-  }
-
-  private autoBlink(): boolean {
-    return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
-  }
-
-  override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this.renderPrefs = renderPrefs;
-
-    // We have to update `imageDiff.automaticBlink` manually, because `this` is
-    // not a LitElement.
-    const imageDiff = this.outputEl.querySelector(
-      'gr-diff-image-new'
-    ) as GrDiffImageNew;
-    if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
-  }
-}
-
 class GrDiffImageNew extends LitElement {
   @property() baseImage?: ImageInfo;
 
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
deleted file mode 100644
index bcc54d4..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import './gr-diff-section';
-import '../gr-context-controls/gr-context-controls';
-import {
-  ContentLoadNeededEventDetail,
-  DiffContextExpandedExternalDetail,
-  DiffViewMode,
-  LineNumber,
-  RenderPreferences,
-} from '../../../api/diff';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {BlameInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {Side} from '../../../constants/constants';
-import {DiffLayer, isDefined} from '../../../types/types';
-import {GrDiffRow} from './gr-diff-row';
-import {GrDiffSection} from './gr-diff-section';
-import {html, render} from 'lit';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
-import {when} from 'lit/directives/when.js';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-
-export interface DiffContextExpandedEventDetail
-  extends DiffContextExpandedExternalDetail {
-  /** The context control group that should be replaced by `groups`. */
-  contextGroup: GrDiffGroup;
-  groups: GrDiffGroup[];
-}
-
-declare global {
-  interface HTMLElementEventMap {
-    'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
-    'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
-    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
-  }
-}
-
-export function isImageDiffBuilder<T extends GrDiffBuilder>(
-  x: T | GrDiffBuilderImage | undefined
-): x is GrDiffBuilderImage {
-  return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
-}
-
-export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
-  x: T | GrDiffBuilderBinary | undefined
-): x is GrDiffBuilderBinary {
-  return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
-}
-
-/**
- * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
- * called sections. Only the builder should add or remove sections from the
- * DOM. Callers can use the ...group() methods to modify groups and thus cause
- * rendering changes.
- */
-export class GrDiffBuilder {
-  private readonly diff: DiffInfo;
-
-  readonly prefs: DiffPreferencesInfo;
-
-  renderPrefs?: RenderPreferences;
-
-  readonly outputEl: HTMLElement;
-
-  private groups: GrDiffGroup[];
-
-  private readonly layerUpdateListener: (
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) => void;
-
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    this.diff = diff;
-    this.prefs = prefs;
-    this.renderPrefs = renderPrefs;
-    this.outputEl = outputEl;
-    this.groups = [];
-
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-      throw Error('Invalid tab size from preferences.');
-    }
-
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-      throw Error('Invalid line length from preferences.');
-    }
-
-    this.layerUpdateListener = (
-      start: LineNumber,
-      end: LineNumber,
-      side: Side
-    ) => this.renderContentByRange(start, end, side);
-    this.init();
-  }
-
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | undefined {
-    if (!side) return undefined;
-    const row = this.findRow(lineNumber, side);
-    return row?.getContentCell(side);
-  }
-
-  getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | undefined {
-    if (!side) return undefined;
-    const row = this.findRow(lineNumber, side);
-    return row?.getLineNumberCell(side);
-  }
-
-  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
-    if (!side || !lineNumber) return undefined;
-    const group = this.findGroup(side, lineNumber);
-    if (!group) return undefined;
-    const section = this.findSection(group);
-    if (!section) return undefined;
-    return section.findRow(side, lineNumber);
-  }
-
-  private getDiffRows() {
-    const sections = [
-      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
-    ];
-    return sections.map(s => s.getDiffRows()).flat();
-  }
-
-  getLineNumberRows(): HTMLTableRowElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getTableRow()).filter(isDefined);
-  }
-
-  getLineNumEls(side: Side): HTMLTableCellElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
-  }
-
-  /** This is used when layers initiate an update. */
-  renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      const section = this.findSection(group);
-      for (const row of section?.getDiffRows() ?? []) {
-        row.requestUpdate();
-      }
-    }
-  }
-
-  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
-    const leftClass = `left-${group.startLine(Side.LEFT)}`;
-    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
-    return (
-      this.outputEl.querySelector<GrDiffSection>(
-        `gr-diff-section.${leftClass}.${rightClass}`
-      ) ?? undefined
-    );
-  }
-
-  buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const leftCl = `left-${group.startLine(Side.LEFT)}`;
-    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
-    const section = html`
-      <gr-diff-section
-        class="${leftCl} ${rightCl}"
-        .group=${group}
-        .diff=${this.diff}
-        .layers=${this.layers}
-        .diffPrefs=${this.prefs}
-        .renderPrefs=${this.renderPrefs}
-      ></gr-diff-section>
-    `;
-    // When using Lit's `render()` method it wants to be in full control of the
-    // element that it renders into, so we let it render into a temp element.
-    // Rendering into the diff table directly would interfere with
-    // `clearDiffContent()`for example.
-    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
-    // method into Lit's `render()` cycle.
-    const tempEl = document.createElement('div');
-    render(section, tempEl);
-    const sectionEl = tempEl.firstElementChild as GrDiffSection;
-    return sectionEl;
-  }
-
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = html`
-      <colgroup>
-        <col class=${diffClasses('blame')}></col>
-        ${when(
-          this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
-          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
-          () => html`
-            ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
-            ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
-          `
-        )}
-      </colgroup>
-    `;
-    // When using Lit's `render()` method it wants to be in full control of the
-    // element that it renders into, so we let it render into a temp element.
-    // Rendering into the diff table directly would interfere with
-    // `clearDiffContent()`for example.
-    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
-    // method into Lit's `render()` cycle.
-    const tempEl = document.createElement('div');
-    render(colgroup, tempEl);
-    const colgroupEl = tempEl.firstElementChild as HTMLElement;
-    outputEl.appendChild(colgroupEl);
-  }
-
-  private renderUnifiedColumns(lineNumberWidth: number) {
-    return html`
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()}></col>
-    `;
-  }
-
-  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
-    return html`
-      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
-      <col class=${diffClasses(side, 'sign')}></col>
-      <col class=${diffClasses(side)}></col>
-    `;
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component re-connects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with cleanup(), which is called
-   * when gr-diff disconnects.
-   */
-  init() {
-    this.cleanup();
-    for (const layer of this.layers) {
-      if (layer.addListener) {
-        layer.addListener(this.layerUpdateListener);
-      }
-    }
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component disconnects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with init(), which is called when
-   * gr-diff re-connects.
-   */
-  cleanup() {
-    for (const layer of this.layers) {
-      if (layer.removeListener) {
-        layer.removeListener(this.layerUpdateListener);
-      }
-    }
-  }
-
-  addGroups(groups: readonly GrDiffGroup[]) {
-    for (const group of groups) {
-      this.groups.push(group);
-      this.emitGroup(group);
-    }
-  }
-
-  clearGroups() {
-    for (const deletedGroup of this.groups) {
-      deletedGroup.element?.remove();
-    }
-    this.groups = [];
-  }
-
-  replaceGroup(contextControl: GrDiffGroup, groups: readonly GrDiffGroup[]) {
-    const i = this.groups.indexOf(contextControl);
-    if (i === -1) throw new Error('cannot find context control group');
-
-    const contextControlSection = this.groups[i].element;
-    if (!contextControlSection) throw new Error('diff group element not set');
-
-    this.groups.splice(i, 1, ...groups);
-    for (const group of groups) {
-      this.emitGroup(group, contextControlSection);
-    }
-    if (contextControlSection) contextControlSection.remove();
-  }
-
-  findGroup(side: Side, line: LineNumber) {
-    return this.groups.find(group => group.containsLine(side, line));
-  }
-
-  private emitGroup(group: GrDiffGroup, beforeSection?: HTMLElement) {
-    const element = this.buildSectionElement(group);
-    this.outputEl.insertBefore(element, beforeSection ?? null);
-    group.element = element;
-  }
-
-  // visible for testing
-  getGroupsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side: Side
-  ): GrDiffGroup[] {
-    const startIndex = this.groups.findIndex(group =>
-      group.containsLine(side, startLine)
-    );
-    if (startIndex === -1) return [];
-    let endIndex = this.groups.findIndex(group =>
-      group.containsLine(side, endLine)
-    );
-    // Not all groups may have been processed yet (i.e. this.groups is still
-    // incomplete). In that case let's just return *all* groups until the end
-    // of the array.
-    if (endIndex === -1) endIndex = this.groups.length - 1;
-    // The filter preserves the legacy behavior to only return non-context
-    // groups
-    return this.groups
-      .slice(startIndex, endIndex + 1)
-      .filter(group => group.lines.length > 0);
-  }
-
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   */
-  setBlame(blame: BlameInfo[]) {
-    for (const blameInfo of blame) {
-      for (const range of blameInfo.ranges) {
-        for (let line = range.start; line <= range.end; line++) {
-          const row = this.findRow(line, Side.LEFT);
-          if (row) row.blameInfo = blameInfo;
-        }
-      }
-    }
-  }
-
-  /**
-   * Only special builders need to implement this. The default is to
-   * just ignore it.
-   */
-  updateRenderPrefs(_: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 51024da..bfd6a0d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -21,6 +21,7 @@
 import {fire} from '../../../utils/event-util';
 import {getBaseUrl} from '../../../utils/url-util';
 import './gr-diff-text';
+import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {diffClasses, isNewDiff, isResponsive} from '../gr-diff/gr-diff-utils';
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index e02d62b..aad7928 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement} from 'lit';
-import {property, state} from 'lit/decorators.js';
+import {property, queryAll, state} from 'lit/decorators.js';
 import {
   DiffInfo,
   DiffLayer,
@@ -28,8 +28,14 @@
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
 import {countLines} from '../../../utils/diff-util';
+import {resolve} from '../../../models/dependency';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {subscribe} from '../../../elements/lit/subscription-controller';
 
 export class GrDiffSection extends LitElement {
+  @queryAll('gr-diff-row')
+  diffRows?: NodeListOf<GrDiffRow>;
+
   @property({type: Object})
   group?: GrDiffGroup;
 
@@ -45,6 +51,9 @@
   @property({type: Object})
   layers: DiffLayer[] = [];
 
+  @state()
+  lineLength = 100;
+
   /**
    * Semantic DOM diff testing does not work with just table fragments, so when
    * running such tests the render() method has to wrap the DOM in a proper
@@ -53,6 +62,24 @@
   @state()
   addTableWrapperForTesting = false;
 
+  @state() viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getDiffModel().lineLength$,
+      lineLength => (this.lineLength = lineLength)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().viewMode$,
+      viewMode => (this.viewMode = viewMode)
+    );
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -64,6 +91,13 @@
     return this;
   }
 
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    const rows = [...(this.diffRows ?? [])];
+    await Promise.all(rows.map(row => row.updateComplete));
+    return result;
+  }
+
   override render() {
     if (!this.group) return;
     const extras: string[] = [];
@@ -84,11 +118,11 @@
       <tbody class=${diffClasses(...extras)}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
         ${pairs.map(pair => {
-          const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
-          const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+          const leftClass = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightClass = `right-${pair.right.lineNumber(Side.RIGHT)}`;
           return html`
             <gr-diff-row
-              class="${leftCl} ${rightCl}"
+              class="${leftClass} ${rightClass}"
               .left=${pair.left}
               .right=${pair.right}
               .layers=${this.layers}
@@ -112,7 +146,7 @@
   }
 
   private isUnifiedDiff() {
-    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+    return this.viewMode === DiffViewMode.UNIFIED;
   }
 
   getLinePairs() {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index 381f9b2..d23c9c5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -73,10 +73,7 @@
     });
 
     test('unified', async () => {
-      element.renderPrefs = {
-        ...element.renderPrefs,
-        view_mode: DiffViewMode.UNIFIED,
-      };
+      element.viewMode = DiffViewMode.UNIFIED;
       const row = await waitQueryAndAssert(element, 'tr.moveControls');
       // Semantic dom diff has a problem with just comparing table rows or
       // cells directly. So as a workaround put the row into an empty test
@@ -162,7 +159,7 @@
                   data-side="left"
                   id="left-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text>asdf</gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -186,7 +183,7 @@
                   data-side="right"
                   id="right-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text>asdf </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
@@ -219,7 +216,7 @@
                   data-side="left"
                   id="left-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text> qwer</gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -243,7 +240,7 @@
                   data-side="right"
                   id="right-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text>qwer </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
@@ -276,7 +273,7 @@
                   data-side="left"
                   id="left-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text>zxcv </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -300,7 +297,7 @@
                   data-side="right"
                   id="right-content-1"
                 >
-                  <gr-diff-text> </gr-diff-text>
+                  <gr-diff-text>zxcv </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index e9076aa..9478d13 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -6,7 +6,10 @@
 import {DiffLayer} from '../../../types/types';
 import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
 import {assertIsDefined} from '../../../utils/common-util';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+  GrAnnotationImpl,
+  getStringLength,
+} from '../gr-diff-highlight/gr-annotation';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 
 import {getLineElByChild, getSideByLineEl} from '../gr-diff/gr-diff-utils';
@@ -147,8 +150,8 @@
       // This is to correctly count surrogate pairs in text and token.
       // If the index calculation becomes a hotspot, we could precompute a code
       // unit to code point index map for text before iterating over the results
-      const index = GrAnnotation.getStringLength(text.slice(0, match.index));
-      const length = GrAnnotation.getStringLength(token);
+      const index = getStringLength(text.slice(0, match.index));
+      const length = getStringLength(token);
 
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
@@ -158,7 +161,7 @@
       // We add the TOKEN_TEXT_PREFIX class so that we can look up the token later easily
       // even if the token element was split up into multiple smaller nodes.
       // All parts of a single token will share a common TOKEN_INDEX_PREFIX class within the line of code.
-      GrAnnotation.annotateElement(
+      GrAnnotationImpl.annotateElement(
         el,
         index,
         length,
@@ -345,7 +348,7 @@
       start_line: line,
       start_column: index + 1, // 1-based inclusive
       end_line: line,
-      end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
+      end_column: index + getStringLength(token), // 1-based inclusive
     };
     this.tokenHighlightListener({token, element, side, range});
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 5651dcf..8d0050f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -11,7 +11,7 @@
 } from '../../../api/diff';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
 import {html, render} from 'lit';
 import {_testOnly_allTasks} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../test/test-utils';
@@ -123,10 +123,13 @@
     }
 
     test('annotate adds css token', () => {
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       const el = createLine('these are words');
       annotate(el);
-      assert.isTrue(annotateElementStub.calledThrice);
+      assert.equal(annotateElementStub.callCount, 3);
       assertAnnotation(annotateElementStub.args[0], {
         parent: el,
         offset: 0,
@@ -148,7 +151,10 @@
     });
 
     test('annotate adds css tokens w/ emojis', () => {
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
 
       annotate(el);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 61f8551..e5aaafd 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -229,7 +229,7 @@
   suite('unified diff', () => {
     setup(async () => {
       diffElement.viewMode = DiffViewMode.UNIFIED;
-      await waitForEventOnce(diffElement, 'render');
+      await diffElement.updateComplete;
       cursor.reInitCursor();
     });
 
@@ -457,9 +457,15 @@
       .callsFake(() => {
         scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
       });
-    diffElement.diff = createDiff();
-    await diffElement.updateComplete;
-    await waitForEventOnce(diffElement, 'render');
+    cursor.dispose();
+    const diff = createDiff();
+    diff.content.push({ab: ['one more line']});
+    diffElement.diff = diff;
+    diffElement.prefs = createDefaultDiffPrefs();
+    await Promise.all([
+      diffElement.updateComplete,
+      waitForEventOnce(diffElement, 'render'),
+    ]);
     cursor.reInitCursor();
     assert.isFalse(moveToNumStub.called);
     assert.isTrue(moveToChunkStub.called);
@@ -478,9 +484,10 @@
     cursor.initialLineNumber = 10;
     cursor.side = Side.RIGHT;
 
-    diffElement.diff = createDiff();
-    await diffElement.updateComplete;
-    await waitForEventOnce(diffElement, 'render');
+    cursor.dispose();
+    const diff = createDiff();
+    diff.content.push({ab: ['one more line']});
+    diffElement.diff = diff;
     cursor.reInitCursor();
     assert.isFalse(moveToChunkStub.called);
     assert.isTrue(moveToNumStub.called);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
index 38bd707..bddfcac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+import {GrAnnotation} from '../../../api/diff';
 
 // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
 const ANNOTATION_TAG = 'HL';
@@ -11,267 +12,271 @@
 // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
 const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-export const GrAnnotation = {
-  /**
-   * The DOM API textContent.length calculation is broken when the text
-   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-   *
-   */
-  getLength(node: Node) {
-    if (node instanceof Comment) return 0;
-    return this.getStringLength(node.textContent || '');
-  },
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ */
+export function getLength(node: Node) {
+  if (node instanceof Comment) return 0;
+  return getStringLength(node.textContent || '');
+}
 
-  /**
-   * Returns the number of Unicode code points in the given string
-   *
-   * This is not necessarily the same as the number of visible symbols.
-   * See https://mathiasbynens.be/notes/javascript-unicode for more details.
-   */
-  getStringLength(str: string) {
-    return [...str].length;
-  },
+/**
+ * Returns the number of Unicode code points in the given string
+ *
+ * This is not necessarily the same as the number of visible symbols.
+ * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+ */
+export function getStringLength(str: string) {
+  return [...str].length;
+}
 
-  /**
-   * Annotates the [offset, offset+length) text segment in the parent with the
-   * element definition provided as arguments.
-   *
-   * @param parent the node whose contents will be annotated.
-   * If parent is Text then parent.parentNode must not be null
-   * @param offset the 0-based offset from which the annotation will
-   * start.
-   * @param length of the annotated text.
-   * @param elementSpec the spec to create the
-   * annotating element.
-   */
-  annotateWithElement(
-    parent: Node,
-    offset: number,
-    length: number,
-    elSpec: ElementSpec
+/**
+ * Annotates the [offset, offset+length) text segment in the parent with the
+ * element definition provided as arguments.
+ *
+ * @param parent the node whose contents will be annotated.
+ * If parent is Text then parent.parentNode must not be null
+ * @param offset the 0-based offset from which the annotation will
+ * start.
+ * @param length of the annotated text.
+ * @param elementSpec the spec to create the
+ * annotating element.
+ */
+export function annotateWithElement(
+  parent: Node,
+  offset: number,
+  length: number,
+  elSpec: ElementSpec
+) {
+  const tagName = elSpec.tagName;
+  const attributes = elSpec.attributes || {};
+  let childNodes: Node[];
+
+  if (parent instanceof Element) {
+    childNodes = Array.from(parent.childNodes);
+  } else if (parent instanceof Text) {
+    childNodes = [parent];
+    parent = parent.parentNode!;
+  } else {
+    return;
+  }
+
+  const nestedNodes: Node[] = [];
+  for (let node of childNodes) {
+    const initialNodeLength = getLength(node);
+    // If the current node is completely before the offset.
+    if (offset > 0 && initialNodeLength <= offset) {
+      offset -= initialNodeLength;
+      continue;
+    }
+
+    if (offset > 0) {
+      node = splitNode(node, offset);
+      offset = 0;
+    }
+    if (getLength(node) > length) {
+      splitNode(node, length);
+    }
+    nestedNodes.push(node);
+
+    length -= getLength(node);
+    if (!length) break;
+  }
+
+  const wrapper = document.createElement(tagName);
+  const sanitizer = getSanitizeDOMValue();
+  for (let [name, value] of Object.entries(attributes)) {
+    if (!value) continue;
+    if (sanitizer) {
+      value = sanitizer(value, name, 'attribute', wrapper) as string;
+    }
+    wrapper.setAttribute(name, value);
+  }
+  for (const inner of nestedNodes) {
+    parent.replaceChild(wrapper, inner);
+    wrapper.appendChild(inner);
+  }
+}
+
+/**
+ * Surrounds the element's text at specified range in an ANNOTATION_TAG
+ * element. If the element has child elements, the range is split and
+ * applied as deeply as possible.
+ */
+export function annotateElement(
+  parent: HTMLElement,
+  offset: number,
+  length: number,
+  cssClass: string
+) {
+  const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
+  let nodeLength;
+  let subLength;
+
+  for (const node of nodes) {
+    nodeLength = getLength(node);
+
+    // If the current node is completely before the offset.
+    if (nodeLength <= offset) {
+      offset -= nodeLength;
+      continue;
+    }
+
+    // Sublength is the annotation length for the current node.
+    subLength = Math.min(length, nodeLength - offset);
+
+    if (node instanceof Text) {
+      _annotateText(node, offset, subLength, cssClass);
+    } else if (node instanceof Element) {
+      annotateElement(node, offset, subLength, cssClass);
+    }
+
+    // If there is still more to annotate, then shift the indices, otherwise
+    // work is done, so break the loop.
+    if (subLength < length) {
+      length -= subLength;
+      offset = 0;
+    } else {
+      break;
+    }
+  }
+}
+
+/**
+ * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+ */
+function wrapInHighlight(node: Element | Text, cssClass: string) {
+  let hl;
+  if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+    hl = node;
+    hl.classList.add(cssClass);
+  } else {
+    hl = document.createElement(ANNOTATION_TAG);
+    hl.className = cssClass;
+    if (node.parentElement) node.parentElement.replaceChild(hl, node);
+    hl.appendChild(node);
+  }
+  return hl;
+}
+
+/**
+ * Splits Text Node and wraps it in hl with cssClass.
+ * Wraps trailing part after split, tailing one if firstPart is true.
+ */
+function splitAndWrapInHighlight(
+  node: Text,
+  offset: number,
+  cssClass: string,
+  firstPart?: boolean
+) {
+  if (
+    (getLength(node) === offset && firstPart) ||
+    (offset === 0 && !firstPart)
   ) {
-    const tagName = elSpec.tagName;
-    const attributes = elSpec.attributes || {};
-    let childNodes: Node[];
+    return wrapInHighlight(node, cssClass);
+  }
+  if (firstPart) {
+    splitNode(node, offset);
+    // Node points to first part of the Text, second one is sibling.
+  } else {
+    // if node is Text then splitNode will return a Text
+    node = splitNode(node, offset) as Text;
+  }
+  return wrapInHighlight(node, cssClass);
+}
 
-    if (parent instanceof Element) {
-      childNodes = Array.from(parent.childNodes);
-    } else if (parent instanceof Text) {
-      childNodes = [parent];
-      parent = parent.parentNode!;
-    } else {
-      return;
+/**
+ * Splits Node at offset.
+ * If Node is Element, it's cloned and the node at offset is split too.
+ */
+function splitNode(element: Node, offset: number) {
+  if (element instanceof Text) {
+    return splitTextNode(element, offset);
+  }
+  const tail = element.cloneNode(false);
+
+  if (element.parentElement)
+    element.parentElement.insertBefore(tail, element.nextSibling);
+  // Skip nodes before offset.
+  let node = element.firstChild;
+  while (node && (getLength(node) <= offset || getLength(node) === 0)) {
+    offset -= getLength(node);
+    node = node.nextSibling;
+  }
+  if (node && getLength(node) > offset) {
+    tail.appendChild(splitNode(node, offset));
+  }
+  while (node && node.nextSibling) {
+    tail.appendChild(node.nextSibling);
+  }
+  return tail;
+}
+
+/**
+ * Node.prototype.splitText Unicode-valid alternative.
+ *
+ * DOM Api for splitText() is broken for Unicode:
+ * https://mathiasbynens.be/notes/javascript-unicode
+ *
+ * @return Trailing Text Node.
+ */
+function splitTextNode(node: Text, offset: number) {
+  if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+    const head = Array.from(node.textContent);
+    const tail = head.splice(offset);
+    const parent = node.parentNode;
+
+    // Split the content of the original node.
+    node.textContent = head.join('');
+
+    const tailNode = document.createTextNode(tail.join(''));
+    if (parent) {
+      parent.insertBefore(tailNode, node.nextSibling);
     }
+    return tailNode;
+  } else {
+    return node.splitText(offset);
+  }
+}
 
-    const nestedNodes: Node[] = [];
-    for (let node of childNodes) {
-      const initialNodeLength = this.getLength(node);
-      // If the current node is completely before the offset.
-      if (offset > 0 && initialNodeLength <= offset) {
-        offset -= initialNodeLength;
-        continue;
-      }
+function _annotateText(
+  node: Text,
+  offset: number,
+  length: number,
+  cssClass: string
+) {
+  const nodeLength = getLength(node);
 
-      if (offset > 0) {
-        node = this.splitNode(node, offset);
-        offset = 0;
-      }
-      if (this.getLength(node) > length) {
-        this.splitNode(node, length);
-      }
-      nestedNodes.push(node);
+  // There are four cases:
+  //  1) Entire node is highlighted.
+  //  2) Highlight is at the start.
+  //  3) Highlight is at the end.
+  //  4) Highlight is in the middle.
 
-      length -= this.getLength(node);
-      if (!length) break;
-    }
+  if (offset === 0 && nodeLength === length) {
+    // Case 1.
+    wrapInHighlight(node, cssClass);
+  } else if (offset === 0) {
+    // Case 2.
+    splitAndWrapInHighlight(node, length, cssClass, true);
+  } else if (offset + length === nodeLength) {
+    // Case 3
+    splitAndWrapInHighlight(node, offset, cssClass, false);
+  } else {
+    // Case 4
+    splitAndWrapInHighlight(
+      splitTextNode(node, offset),
+      length,
+      cssClass,
+      true
+    );
+  }
+}
 
-    const wrapper = document.createElement(tagName);
-    const sanitizer = getSanitizeDOMValue();
-    for (let [name, value] of Object.entries(attributes)) {
-      if (!value) continue;
-      if (sanitizer) {
-        value = sanitizer(value, name, 'attribute', wrapper) as string;
-      }
-      wrapper.setAttribute(name, value);
-    }
-    for (const inner of nestedNodes) {
-      parent.replaceChild(wrapper, inner);
-      wrapper.appendChild(inner);
-    }
-  },
-
-  /**
-   * Surrounds the element's text at specified range in an ANNOTATION_TAG
-   * element. If the element has child elements, the range is split and
-   * applied as deeply as possible.
-   */
-  annotateElement(
-    parent: HTMLElement,
-    offset: number,
-    length: number,
-    cssClass: string
-  ) {
-    const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
-    let nodeLength;
-    let subLength;
-
-    for (const node of nodes) {
-      nodeLength = this.getLength(node);
-
-      // If the current node is completely before the offset.
-      if (nodeLength <= offset) {
-        offset -= nodeLength;
-        continue;
-      }
-
-      // Sublength is the annotation length for the current node.
-      subLength = Math.min(length, nodeLength - offset);
-
-      if (node instanceof Text) {
-        this._annotateText(node, offset, subLength, cssClass);
-      } else if (node instanceof Element) {
-        this.annotateElement(node, offset, subLength, cssClass);
-      }
-
-      // If there is still more to annotate, then shift the indices, otherwise
-      // work is done, so break the loop.
-      if (subLength < length) {
-        length -= subLength;
-        offset = 0;
-      } else {
-        break;
-      }
-    }
-  },
-
-  /**
-   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
-   */
-  wrapInHighlight(node: Element | Text, cssClass: string) {
-    let hl;
-    if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
-      hl = node;
-      hl.classList.add(cssClass);
-    } else {
-      hl = document.createElement(ANNOTATION_TAG);
-      hl.className = cssClass;
-      if (node.parentElement) node.parentElement.replaceChild(hl, node);
-      hl.appendChild(node);
-    }
-    return hl;
-  },
-
-  /**
-   * Splits Text Node and wraps it in hl with cssClass.
-   * Wraps trailing part after split, tailing one if firstPart is true.
-   */
-  splitAndWrapInHighlight(
-    node: Text,
-    offset: number,
-    cssClass: string,
-    firstPart?: boolean
-  ) {
-    if (
-      (this.getLength(node) === offset && firstPart) ||
-      (offset === 0 && !firstPart)
-    ) {
-      return this.wrapInHighlight(node, cssClass);
-    }
-    if (firstPart) {
-      this.splitNode(node, offset);
-      // Node points to first part of the Text, second one is sibling.
-    } else {
-      // if node is Text then splitNode will return a Text
-      node = this.splitNode(node, offset) as Text;
-    }
-    return this.wrapInHighlight(node, cssClass);
-  },
-
-  /**
-   * Splits Node at offset.
-   * If Node is Element, it's cloned and the node at offset is split too.
-   */
-  splitNode(element: Node, offset: number) {
-    if (element instanceof Text) {
-      return this.splitTextNode(element, offset);
-    }
-    const tail = element.cloneNode(false);
-
-    if (element.parentElement)
-      element.parentElement.insertBefore(tail, element.nextSibling);
-    // Skip nodes before offset.
-    let node = element.firstChild;
-    while (
-      node &&
-      (this.getLength(node) <= offset || this.getLength(node) === 0)
-    ) {
-      offset -= this.getLength(node);
-      node = node.nextSibling;
-    }
-    if (node && this.getLength(node) > offset) {
-      tail.appendChild(this.splitNode(node, offset));
-    }
-    while (node && node.nextSibling) {
-      tail.appendChild(node.nextSibling);
-    }
-    return tail;
-  },
-
-  /**
-   * Node.prototype.splitText Unicode-valid alternative.
-   *
-   * DOM Api for splitText() is broken for Unicode:
-   * https://mathiasbynens.be/notes/javascript-unicode
-   *
-   * @return Trailing Text Node.
-   */
-  splitTextNode(node: Text, offset: number) {
-    if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
-      const head = Array.from(node.textContent);
-      const tail = head.splice(offset);
-      const parent = node.parentNode;
-
-      // Split the content of the original node.
-      node.textContent = head.join('');
-
-      const tailNode = document.createTextNode(tail.join(''));
-      if (parent) {
-        parent.insertBefore(tailNode, node.nextSibling);
-      }
-      return tailNode;
-    } else {
-      return node.splitText(offset);
-    }
-  },
-
-  _annotateText(node: Text, offset: number, length: number, cssClass: string) {
-    const nodeLength = this.getLength(node);
-
-    // There are four cases:
-    //  1) Entire node is highlighted.
-    //  2) Highlight is at the start.
-    //  3) Highlight is at the end.
-    //  4) Highlight is in the middle.
-
-    if (offset === 0 && nodeLength === length) {
-      // Case 1.
-      this.wrapInHighlight(node, cssClass);
-    } else if (offset === 0) {
-      // Case 2.
-      this.splitAndWrapInHighlight(node, length, cssClass, true);
-    } else if (offset + length === nodeLength) {
-      // Case 3
-      this.splitAndWrapInHighlight(node, offset, cssClass, false);
-    } else {
-      // Case 4
-      this.splitAndWrapInHighlight(
-        this.splitTextNode(node, offset),
-        length,
-        cssClass,
-        true
-      );
-    }
-  },
+export const GrAnnotationImpl: GrAnnotation = {
+  annotateElement,
+  annotateWithElement,
 };
 
 /**
@@ -282,3 +287,8 @@
   tagName: string;
   attributes?: {[attributeName: string]: string | undefined};
 }
+
+export const TEST_ONLY = {
+  _annotateText,
+  splitTextNode,
+};
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
index 3e1ce66..15a6a15 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -4,7 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {GrAnnotation} from './gr-annotation';
+import {
+  TEST_ONLY,
+  annotateElement,
+  annotateWithElement,
+  getStringLength,
+} from './gr-annotation';
 import {
   getSanitizeDOMValue,
   setSanitizeDOMValue,
@@ -27,7 +32,7 @@
   });
 
   test('_annotateText length:0 offset:0', () => {
-    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -37,7 +42,7 @@
   });
 
   test('_annotateText length:0 offset:1', () => {
-    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, 1, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -47,7 +52,7 @@
   });
 
   test('_annotateText length:0 offset:str.length', () => {
-    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, str.length, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -57,7 +62,7 @@
   });
 
   test('_annotateText Case 1', () => {
-    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, str.length, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -67,7 +72,7 @@
   });
 
   test('_annotateText Case 2', () => {
-    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, 12, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -77,7 +82,7 @@
   });
 
   test('_annotateText Case 3', () => {
-    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+    TEST_ONLY._annotateText(textNode, 12, str.length - 12, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -90,7 +95,7 @@
     const index = str.indexOf('dolor');
     const length = 'dolor '.length;
 
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+    TEST_ONLY._annotateText(textNode, index, length, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -104,7 +109,7 @@
 
     // Apply the layers successively.
     layers.forEach((layer, i) => {
-      GrAnnotation.annotateElement(
+      annotateElement(
         parent,
         str.indexOf(layer),
         layer.length,
@@ -129,13 +134,13 @@
 
     // Non-unicode path:
     node = document.createTextNode(helloString + asciiString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    tail = TEST_ONLY.splitTextNode(node, helloString.length);
     assert(node.textContent, helloString);
     assert(tail.textContent, asciiString);
 
     // Unicdoe path:
     node = document.createTextNode(helloString + unicodeString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    tail = TEST_ONLY.splitTextNode(node, helloString.length);
     assert(node.textContent, helloString);
     assert(tail.textContent, unicodeString);
   });
@@ -166,7 +171,7 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -180,8 +185,8 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateElement(container, 5, length, 'testclass');
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -201,7 +206,7 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+      annotateWithElement(container.childNodes[0], 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -216,7 +221,7 @@
       container.appendChild(document.createTextNode('0123456789'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
+      annotateWithElement(container, 1, 10, {
         tagName: 'test-wrapper',
       });
 
@@ -233,7 +238,7 @@
       container.appendChild(document.createComment('comment2'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
+      annotateWithElement(container, 1, 10, {
         tagName: 'test-wrapper',
       });
 
@@ -254,7 +259,7 @@
         'data-foo': 'bar',
         class: 'hello world',
       };
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
         attributes,
       });
@@ -291,17 +296,17 @@
 
   suite('getStringLength', () => {
     test('ASCII characters are counted correctly', () => {
-      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+      assert.equal(getStringLength('ASCII'), 5);
     });
 
     test('Unicode surrogate pairs count as one symbol', () => {
-      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
-      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+      assert.equal(getStringLength('Unic💢de'), 7);
+      assert.equal(getStringLength('💢💢'), 2);
     });
 
     test('Grapheme clusters count as multiple symbols', () => {
-      assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
-      assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+      assert.equal(getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(getStringLength('q\u0307\u0323'), 3); // q̣̇
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 0d9250c..1cdfbc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -5,7 +5,7 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {GrAnnotation} from './gr-annotation';
+import {getLength} from './gr-annotation';
 import {normalize} from './gr-range-normalizer';
 import {strToClassName} from '../../../utils/dom-util';
 import {Side} from '../../../constants/constants';
@@ -508,7 +508,7 @@
     if (node instanceof Element && node.classList.contains('content')) {
       return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
-      return GrAnnotation.getLength(node);
+      return getLength(node);
     }
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 645de1b..4f05cca 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -150,211 +150,218 @@
   // TODO(hermannloose): Make GrLibLoader a singleton.
   private static readonly libLoader = new GrLibLoader();
 
-  static override styles = css`
-    :host {
-      display: grid;
-      grid-template-rows: 1fr auto;
-      grid-template-columns: 1fr auto;
-      width: 100%;
-      height: 100%;
-      box-sizing: border-box;
-      text-align: initial !important;
-      font-size: var(--font-size-normal);
-      --image-border-width: 2px;
-    }
-    .imageArea {
-      grid-row-start: 1;
-      grid-column-start: 1;
-      box-sizing: border-box;
-      flex-grow: 1;
-      overflow: hidden;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      margin: var(--spacing-m);
-      padding: var(--image-border-width);
-      max-height: 100%;
-      position: relative;
-    }
-    #spacer {
-      visibility: hidden;
-    }
-    gr-zoomed-image {
-      border: var(--image-border-width) solid;
-      margin: calc(-1 * var(--image-border-width));
-      box-sizing: content-box;
-      position: absolute;
-      overflow: hidden;
-      cursor: pointer;
-    }
-    gr-zoomed-image.base {
-      border-color: var(--base-image-border-color, rgb(255, 205, 210));
-    }
-    gr-zoomed-image.revision {
-      border-color: var(--revision-image-border-color, rgb(170, 242, 170));
-    }
-    #automatic-blink-button {
-      position: absolute;
-      right: var(--spacing-xl);
-      bottom: var(--spacing-xl);
-      opacity: 0;
-      transition: opacity 200ms ease;
-      --paper-fab-background: var(--primary-button-background-color);
-      --paper-fab-keyboard-focus-background: var(
-        --primary-button-background-color
-      );
-    }
-    #automatic-blink-button.show,
-    #automatic-blink-button:focus-visible {
-      opacity: 1;
-    }
-    .checkerboard {
-      --square-size: var(--checkerboard-square-size, 10px);
-      --square-color: var(--checkerboard-square-color, #808080);
-      background-color: var(--checkerboard-background-color, #aaaaaa);
-      background-image: linear-gradient(
-          45deg,
-          var(--square-color) 25%,
-          transparent 25%
-        ),
-        linear-gradient(-45deg, var(--square-color) 25%, transparent 25%),
-        linear-gradient(45deg, transparent 75%, var(--square-color) 75%),
-        linear-gradient(-45deg, transparent 75%, var(--square-color) 75%);
-      background-size: calc(var(--square-size) * 2) calc(var(--square-size) * 2);
-      background-position: 0 0, 0 var(--square-size),
-        var(--square-size) calc(-1 * var(--square-size)),
-        calc(-1 * var(--square-size)) 0;
-    }
-    .dimensions {
-      grid-row-start: 2;
-      justify-self: center;
-      align-self: center;
-      background: var(--primary-button-background-color);
-      color: var(--primary-button-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      line-height: var(--line-height-small);
-      border-radius: var(--border-radius, 4px);
-      margin: var(--spacing-s);
-      padding: var(--spacing-xxs) var(--spacing-s);
-    }
-    .controls {
-      grid-column-start: 2;
-      flex-grow: 0;
-      display: flex;
-      flex-direction: column;
-      align-self: flex-start;
-      margin: var(--spacing-m);
-      padding-bottom: var(--spacing-xl);
-    }
-    paper-button {
-      padding: var(--spacing-m);
-      font: var(--image-diff-button-font);
-      text-transform: var(--image-diff-button-text-transform, uppercase);
-      outline: 1px solid transparent;
-      border: 1px solid var(--primary-button-background-color);
-    }
-    paper-button.unelevated {
-      color: var(--primary-button-text-color);
-      background-color: var(--primary-button-background-color);
-    }
-    paper-button.outlined {
-      color: var(--primary-button-background-color);
-    }
-    #version-switcher {
-      display: flex;
-      align-items: center;
-      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
-      /* Start a stacking context to contain FAB below. */
-      z-index: 0;
-    }
-    #version-switcher paper-button {
-      flex-grow: 1;
-      margin: 0;
-      /*
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: grid;
+          grid-template-rows: 1fr auto;
+          grid-template-columns: 1fr auto;
+          width: 100%;
+          height: 100%;
+          box-sizing: border-box;
+          text-align: initial !important;
+          font-size: var(--font-size-normal);
+          --image-border-width: 2px;
+        }
+        .imageArea {
+          grid-row-start: 1;
+          grid-column-start: 1;
+          box-sizing: border-box;
+          flex-grow: 1;
+          overflow: hidden;
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          margin: var(--spacing-m);
+          padding: var(--image-border-width);
+          max-height: 100%;
+          position: relative;
+        }
+        #spacer {
+          visibility: hidden;
+        }
+        gr-zoomed-image {
+          border: var(--image-border-width) solid;
+          margin: calc(-1 * var(--image-border-width));
+          box-sizing: content-box;
+          position: absolute;
+          overflow: hidden;
+          cursor: pointer;
+        }
+        gr-zoomed-image.base {
+          border-color: var(--base-image-border-color, rgb(255, 205, 210));
+        }
+        gr-zoomed-image.revision {
+          border-color: var(--revision-image-border-color, rgb(170, 242, 170));
+        }
+        #automatic-blink-button {
+          position: absolute;
+          right: var(--spacing-xl);
+          bottom: var(--spacing-xl);
+          opacity: 0;
+          transition: opacity 200ms ease;
+          --paper-fab-background: var(--primary-button-background-color);
+          --paper-fab-keyboard-focus-background: var(
+            --primary-button-background-color
+          );
+        }
+        #automatic-blink-button.show,
+        #automatic-blink-button:focus-visible {
+          opacity: 1;
+        }
+        .checkerboard {
+          --square-size: var(--checkerboard-square-size, 10px);
+          --square-color: var(--checkerboard-square-color, #808080);
+          background-color: var(--checkerboard-background-color, #aaaaaa);
+          background-image: linear-gradient(
+              45deg,
+              var(--square-color) 25%,
+              transparent 25%
+            ),
+            linear-gradient(-45deg, var(--square-color) 25%, transparent 25%),
+            linear-gradient(45deg, transparent 75%, var(--square-color) 75%),
+            linear-gradient(-45deg, transparent 75%, var(--square-color) 75%);
+          background-size: calc(var(--square-size) * 2)
+            calc(var(--square-size) * 2);
+          background-position: 0 0, 0 var(--square-size),
+            var(--square-size) calc(-1 * var(--square-size)),
+            calc(-1 * var(--square-size)) 0;
+        }
+        .dimensions {
+          grid-row-start: 2;
+          justify-self: center;
+          align-self: center;
+          background: var(--primary-button-background-color);
+          color: var(--primary-button-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          line-height: var(--line-height-small);
+          border-radius: var(--border-radius, 4px);
+          margin: var(--spacing-s);
+          padding: var(--spacing-xxs) var(--spacing-s);
+        }
+        .controls {
+          grid-column-start: 2;
+          flex-grow: 0;
+          display: flex;
+          flex-direction: column;
+          align-self: flex-start;
+          margin: var(--spacing-m);
+          padding-bottom: var(--spacing-xl);
+        }
+        paper-button {
+          padding: var(--spacing-m);
+          font: var(--image-diff-button-font);
+          text-transform: var(--image-diff-button-text-transform, uppercase);
+          outline: 1px solid transparent;
+          border: 1px solid var(--primary-button-background-color);
+        }
+        paper-button.unelevated {
+          color: var(--primary-button-text-color);
+          background-color: var(--primary-button-background-color);
+        }
+        paper-button.outlined {
+          color: var(--primary-button-background-color);
+        }
+        #version-switcher {
+          display: flex;
+          align-items: center;
+          margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+          /* Start a stacking context to contain FAB below. */
+          z-index: 0;
+        }
+        #version-switcher paper-button {
+          flex-grow: 1;
+          margin: 0;
+          /*
         The floating action button below overlaps part of the version buttons.
         This min-width ensures the button text still appears somewhat balanced.
         */
-      min-width: 7rem;
-    }
-    #version-switcher paper-fab {
-      /* Round button overlaps Base and Revision buttons. */
-      z-index: 1;
-      margin: 0 -12px;
-      /* Styled as an outlined button. */
-      color: var(--primary-button-background-color);
-      border: 1px solid var(--primary-button-background-color);
-      --paper-fab-background: var(--primary-background-color);
-      --paper-fab-keyboard-focus-background: var(--primary-background-color);
-    }
-    #version-explanation {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
-    }
-    #highlight-changes {
-      margin: var(--spacing-m) var(--spacing-xl);
-    }
-    gr-overview-image {
-      min-width: 200px;
-      min-height: 150px;
-      margin-top: var(--spacing-m);
-    }
-    #zoom-control {
-      margin: 0 var(--spacing-xl);
-    }
-    paper-item {
-      cursor: pointer;
-    }
-    paper-item:hover {
-      background-color: var(--hover-background-color);
-    }
-    #follow-mouse {
-      margin: var(--spacing-m) var(--spacing-xl);
-    }
-    .color-picker {
-      margin: var(--spacing-m) var(--spacing-xl) 0;
-    }
-    .color-picker .label {
-      margin-bottom: var(--spacing-s);
-    }
-    .color-picker .options {
-      display: flex;
-      /* Ignore selection border for alignment, for visual balance. */
-      margin-left: -3px;
-    }
-    .color-picker-button {
-      border-width: 2px;
-      border-style: solid;
-      border-color: transparent;
-      border-radius: 50%;
-      width: 24px;
-      height: 24px;
-      padding: 1px;
-    }
-    .color-picker-button.selected {
-      border-color: var(--primary-button-background-color);
-    }
-    .color-picker-button:focus-within:not(.selected) {
-      /* Not an actual outline, as those do not follow border-radius. */
-      border-color: var(--outline-color-focus);
-    }
-    .color-picker-button .color {
-      border: 1px solid var(--border-color);
-      border-radius: 50%;
-      width: 100%;
-      height: 100%;
-      box-sizing: border-box;
-    }
-    #source-plus-highlight-container {
-      position: relative;
-    }
-    #source-plus-highlight-container img {
-      position: absolute;
-      top: 0;
-      left: 0;
-    }
-  `;
+          min-width: 7rem;
+        }
+        #version-switcher paper-fab {
+          /* Round button overlaps Base and Revision buttons. */
+          z-index: 1;
+          margin: 0 -12px;
+          /* Styled as an outlined button. */
+          color: var(--primary-button-background-color);
+          border: 1px solid var(--primary-button-background-color);
+          --paper-fab-background: var(--primary-background-color);
+          --paper-fab-keyboard-focus-background: var(
+            --primary-background-color
+          );
+        }
+        #version-explanation {
+          color: var(--deemphasized-text-color);
+          text-align: center;
+          margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+        }
+        #highlight-changes {
+          margin: var(--spacing-m) var(--spacing-xl);
+        }
+        gr-overview-image {
+          min-width: 200px;
+          min-height: 150px;
+          margin-top: var(--spacing-m);
+        }
+        #zoom-control {
+          margin: 0 var(--spacing-xl);
+        }
+        paper-item {
+          cursor: pointer;
+        }
+        paper-item:hover {
+          background-color: var(--hover-background-color);
+        }
+        #follow-mouse {
+          margin: var(--spacing-m) var(--spacing-xl);
+        }
+        .color-picker {
+          margin: var(--spacing-m) var(--spacing-xl) 0;
+        }
+        .color-picker .label {
+          margin-bottom: var(--spacing-s);
+        }
+        .color-picker .options {
+          display: flex;
+          /* Ignore selection border for alignment, for visual balance. */
+          margin-left: -3px;
+        }
+        .color-picker-button {
+          border-width: 2px;
+          border-style: solid;
+          border-color: transparent;
+          border-radius: 50%;
+          width: 24px;
+          height: 24px;
+          padding: 1px;
+        }
+        .color-picker-button.selected {
+          border-color: var(--primary-button-background-color);
+        }
+        .color-picker-button:focus-within:not(.selected) {
+          /* Not an actual outline, as those do not follow border-radius. */
+          border-color: var(--outline-color-focus);
+        }
+        .color-picker-button .color {
+          border: 1px solid var(--border-color);
+          border-radius: 50%;
+          width: 100%;
+          height: 100%;
+          box-sizing: border-box;
+        }
+        #source-plus-highlight-container {
+          position: relative;
+        }
+        #source-plus-highlight-container img {
+          position: absolute;
+          top: 0;
+          left: 0;
+        }
+      `,
+    ];
+  }
 
   private renderColorPickerButton(color: string, colorPicked: () => void) {
     const selected =
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 21a7cf8..a05b5e2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -75,39 +75,43 @@
     }
   );
 
-  static override styles = css`
-    :host {
-      --background-color: var(--overview-image-background-color, #000);
-      --frame-color: var(--overview-image-frame-color, #f00);
-      display: flex;
-    }
-    * {
-      box-sizing: border-box;
-    }
-    ::slotted(*) {
-      display: block;
-    }
-    .content-box {
-      border: 1px solid var(--background-color);
-      background-color: var(--background-color);
-      width: 100%;
-      position: relative;
-    }
-    .content {
-      position: absolute;
-      cursor: pointer;
-    }
-    .content-transform {
-      position: absolute;
-      transform-origin: top left;
-      will-change: transform;
-    }
-    .frame {
-      border: 1px solid var(--frame-color);
-      position: absolute;
-      will-change: transform;
-    }
-  `;
+  static override get styles() {
+    return [
+      css`
+        :host {
+          --background-color: var(--overview-image-background-color, #000);
+          --frame-color: var(--overview-image-frame-color, #f00);
+          display: flex;
+        }
+        * {
+          box-sizing: border-box;
+        }
+        ::slotted(*) {
+          display: block;
+        }
+        .content-box {
+          border: 1px solid var(--background-color);
+          background-color: var(--background-color);
+          width: 100%;
+          position: relative;
+        }
+        .content {
+          position: absolute;
+          cursor: pointer;
+        }
+        .content-transform {
+          position: absolute;
+          transform-origin: top left;
+          will-change: transform;
+        }
+        .frame {
+          border: 1px solid var(--frame-color);
+          position: absolute;
+          will-change: transform;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index 7b46c51..3b778b1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -25,25 +25,29 @@
 
   @state() protected imageStyles: StyleInfo = {};
 
-  static override styles = css`
-    :host {
-      display: block;
-    }
-    ::slotted(*) {
-      display: block;
-    }
-    #clip {
-      position: relative;
-      width: 100%;
-      height: 100%;
-      overflow: hidden;
-    }
-    #transform {
-      position: absolute;
-      transform-origin: top left;
-      will-change: transform;
-    }
-  `;
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        ::slotted(*) {
+          display: block;
+        }
+        #clip {
+          position: relative;
+          width: 100%;
+          height: 100%;
+          overflow: hidden;
+        }
+        #transform {
+          position: absolute;
+          transform-origin: top left;
+          will-change: transform;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index d2e997c..8dae154 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -3,59 +3,158 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Observable} from 'rxjs';
-import {filter} from 'rxjs/operators';
+import {Observable, combineLatest, from} from 'rxjs';
+import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
 import {
   DiffInfo,
   DiffPreferencesInfo,
+  DiffViewMode,
   DisplayLine,
   RenderPreferences,
 } from '../../../api/diff';
 import {define} from '../../../models/dependency';
 import {Model} from '../../../models/model';
-import {isDefined} from '../../../types/types';
 import {select} from '../../../utils/observable-util';
 import {
+  FullContext,
   GrDiffCommentThread,
   KeyLocations,
+  computeContext,
   computeKeyLocations,
+  computeLineLength,
 } from '../gr-diff/gr-diff-utils';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  GrDiffProcessor,
+  ProcessingOptions,
+} from '../gr-diff-processor/gr-diff-processor';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {assert} from '../../../utils/common-util';
 
 export interface DiffState {
-  diff: DiffInfo;
+  diff?: DiffInfo;
   path?: string;
   renderPrefs: RenderPreferences;
   diffPrefs: DiffPreferencesInfo;
   lineOfInterest?: DisplayLine;
   comments: GrDiffCommentThread[];
+  groups: GrDiffGroup[];
+  /** how much context to show for large files */
+  showFullContext: FullContext;
+  isImageDiff: boolean;
 }
 
 export const diffModelToken = define<DiffModel>('diff-model');
 
-export class DiffModel extends Model<DiffState | undefined> {
+export class DiffModel extends Model<DiffState> {
   readonly diff$: Observable<DiffInfo> = select(
-    this.state$.pipe(filter(isDefined)),
-    diffState => diffState.diff
+    this.state$.pipe(filter(state => state.diff !== undefined)),
+    diffState => diffState.diff!
   );
 
   readonly path$: Observable<string | undefined> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.path
   );
 
   readonly renderPrefs$: Observable<RenderPreferences> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.renderPrefs
   );
 
+  readonly viewMode$: Observable<DiffViewMode> = select(
+    this.renderPrefs$,
+    renderPrefs => renderPrefs.view_mode ?? DiffViewMode.SIDE_BY_SIDE
+  );
+
   readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.diffPrefs
   );
 
+  readonly context$: Observable<number> = select(this.state$, state =>
+    computeContext(
+      state.diffPrefs.context,
+      state.showFullContext,
+      createDefaultDiffPrefs().context
+    )
+  );
+
+  readonly isImageDiff$: Observable<boolean> = select(
+    this.state$,
+    diffState => diffState.isImageDiff
+  );
+
+  readonly groups$: Observable<GrDiffGroup[]> = select(
+    this.state$,
+    diffState => diffState.groups ?? []
+  );
+
+  readonly lineLength$: Observable<number> = select(this.state$, state =>
+    computeLineLength(state.diffPrefs, state.path)
+  );
+
   readonly keyLocations$: Observable<KeyLocations> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState =>
       computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
   );
+
+  constructor() {
+    super({
+      diffPrefs: createDefaultDiffPrefs(),
+      renderPrefs: {},
+      comments: [],
+      groups: [],
+      showFullContext: FullContext.UNDECIDED,
+      isImageDiff: false,
+    });
+    this.subscriptions = [this.processDiff()];
+  }
+
+  processDiff() {
+    return combineLatest([
+      this.diff$,
+      this.context$,
+      this.renderPrefs$,
+      this.isImageDiff$,
+    ])
+      .pipe(
+        withLatestFrom(this.keyLocations$),
+        debounceTime(1),
+        switchMap(
+          ([[diff, context, renderPrefs, isImageDiff], keyLocations]) => {
+            const options: ProcessingOptions = {
+              context,
+              keyLocations,
+              isBinary: !!(isImageDiff || diff.binary),
+            };
+            if (renderPrefs?.num_lines_rendered_at_once) {
+              options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+            }
+            const processor = new GrDiffProcessor(options);
+            return from(processor.process(diff.content));
+          }
+        )
+      )
+      .subscribe(groups => {
+        this.updateState({groups});
+      });
+  }
+
+  /**
+   * Replace a context control group with some expanded groups. Happens when the
+   * user clicks "+10" or something similar.
+   */
+  replaceGroup(group: GrDiffGroup, newGroups: readonly GrDiffGroup[]) {
+    assert(
+      group.type === GrDiffGroupType.CONTEXT_CONTROL,
+      'gr-diff can only replace context control groups'
+    );
+    const groups = [...this.getState().groups];
+    const i = groups.indexOf(group);
+    if (i === -1) throw new Error('cannot find context control group');
+    groups.splice(i, 1, ...newGroups);
+    this.updateState({groups});
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 483a4da..5db6db9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -13,11 +13,9 @@
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assert} from '../../../utils/common-util';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {FILE, GrDiffLineType, LineNumber} from '../../../api/diff';
-import {KeyLocations} from '../gr-diff/gr-diff-utils';
-
-const WHOLE_FILE = -1;
+import {getStringLength} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLineType, LineNumber} from '../../../api/diff';
+import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
 
 // visible for testing
 export interface State {
@@ -107,11 +105,9 @@
 
   private resetIsScrollingTask?: DelayedTask;
 
-  constructor(
-    private consumer: GroupConsumer | undefined,
-    options: ProcessingOptions
-  ) {
-    this.consumer = consumer;
+  private readonly groups: GrDiffGroup[] = [];
+
+  constructor(options: ProcessingOptions) {
     this.context = options.context;
     this.asyncThreshold = options.asyncThreshold ?? 64;
     this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
@@ -134,62 +130,24 @@
    * @return A promise that resolves with an
    * array of GrDiffGroups when the diff is completely processed.
    */
-  process(chunks: DiffContent[]) {
+  async process(chunks: DiffContent[]): Promise<GrDiffGroup[]> {
     assert(this.isStarted === false, 'diff processor cannot be started twice');
 
     window.addEventListener('scroll', this.handleWindowScroll);
 
-    this.consumer?.clearGroups();
-    this.consumer?.addGroup(this.makeGroup('LOST'));
-    this.consumer?.addGroup(this.makeGroup(FILE));
+    this.groups.push(this.makeGroup('LOST'));
+    this.groups.push(this.makeGroup('FILE'));
 
-    if (this.isBinary) return Promise.resolve();
-
-    return new Promise<void>(resolve => {
-      const state = {
-        lineNums: {left: 0, right: 0},
-        chunkIndex: 0,
-      };
-
-      chunks = this.splitLargeChunks(chunks);
-      chunks = this.splitCommonChunksWithKeyLocations(chunks);
-
-      let currentBatch = 0;
-      const nextStep = () => {
-        if (this.isCancelled || state.chunkIndex >= chunks.length) {
-          resolve();
-          return;
-        }
-        if (this.isScrolling) {
-          window.setTimeout(nextStep, 100);
-          return;
-        }
-
-        const stateUpdate = this.processNext(state, chunks);
-        for (const group of stateUpdate.groups) {
-          this.consumer?.addGroup(group);
-          currentBatch += group.lines.length;
-        }
-        state.lineNums.left += stateUpdate.lineDelta.left;
-        state.lineNums.right += stateUpdate.lineDelta.right;
-
-        state.chunkIndex = stateUpdate.newChunkIndex;
-        if (currentBatch >= this.asyncThreshold) {
-          currentBatch = 0;
-          window.setTimeout(nextStep, 1);
-        } else {
-          nextStep.call(this);
-        }
-      };
-
-      nextStep.call(this);
-    }).finally(() => {
+    if (this.isBinary) return this.groups;
+    try {
+      await this.processChunks(chunks);
+    } finally {
       this.finish();
-    });
+    }
+    return this.groups;
   }
 
   finish() {
-    this.consumer = undefined;
     window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
@@ -198,6 +156,50 @@
     this.finish();
   }
 
+  async processChunks(chunks: DiffContent[]) {
+    let completed = () => {};
+    const promise = new Promise<void>(resolve => (completed = resolve));
+
+    const state = {
+      lineNums: {left: 0, right: 0},
+      chunkIndex: 0,
+    };
+
+    chunks = this.splitLargeChunks(chunks);
+    chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+    let currentBatch = 0;
+    const nextStep = () => {
+      if (this.isCancelled || state.chunkIndex >= chunks.length) {
+        completed();
+        return;
+      }
+      if (this.isScrolling) {
+        window.setTimeout(nextStep, 100);
+        return;
+      }
+
+      const stateUpdate = this.processNext(state, chunks);
+      for (const group of stateUpdate.groups) {
+        this.groups.push(group);
+        currentBatch += group.lines.length;
+      }
+      state.lineNums.left += stateUpdate.lineDelta.left;
+      state.lineNums.right += stateUpdate.lineDelta.right;
+
+      state.chunkIndex = stateUpdate.newChunkIndex;
+      if (currentBatch >= this.asyncThreshold) {
+        currentBatch = 0;
+        window.setTimeout(nextStep, 1);
+      } else {
+        nextStep.call(this);
+      }
+    };
+
+    nextStep.call(this);
+    await promise;
+  }
+
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
@@ -286,7 +288,7 @@
     );
 
     const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
       const contextNumLines = this.context > 0 ? this.context : 0;
       const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
       const hiddenEnd =
@@ -477,7 +479,10 @@
       // enabled for any other context preference because manipulating the
       // chunks in this way violates assumptions by the context grouper logic.
       const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
-      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+      if (
+        this.context === FULL_CONTEXT &&
+        chunk.ab.length > MAX_GROUP_SIZE * 2
+      ) {
         // Split large shared chunks in two, where the first is the maximum
         // group size.
         newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
@@ -628,7 +633,7 @@
     intralineInfos: number[][]
   ): Highlights[] {
     // +1 to account for the \n that is not part of the rows passed here
-    const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+    const lineLengths = rows.map(r => getStringLength(r) + 1);
 
     let rowIndex = 0;
     let idx = 0;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 706c208..3485fe4 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -7,18 +7,13 @@
 import './gr-diff-processor';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {
-  GrDiffProcessor,
-  GroupConsumer,
-  ProcessingOptions,
-  State,
-} from './gr-diff-processor';
+import {GrDiffProcessor, ProcessingOptions, State} from './gr-diff-processor';
 import {DiffContent} from '../../../types/diff';
 import {assert} from '@open-wc/testing';
 import {FILE, GrDiffLineType} from '../../../api/diff';
+import {FULL_CONTEXT} from '../gr-diff/gr-diff-utils';
 
 suite('gr-diff-processor tests', () => {
-  const WHOLE_FILE = -1;
   const loremIpsum =
     'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
     'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
@@ -30,26 +25,16 @@
   let options: ProcessingOptions = {
     context: 4,
   };
-  let groups: GrDiffGroup[];
-  const consumer: GroupConsumer = {
-    addGroup(group: GrDiffGroup) {
-      groups.push(group);
-    },
-    clearGroups() {
-      groups = [];
-    },
-  };
 
   setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
-      groups = [];
       options = {context: 4};
-      processor = new GrDiffProcessor(consumer, options);
+      processor = new GrDiffProcessor(options);
     });
 
-    test('process loaded content', () => {
+    test('process loaded content', async () => {
       const content: DiffContent[] = [
         {
           ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
@@ -67,82 +52,80 @@
         },
       ];
 
-      return processor.process(content).then(() => {
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-        assert.equal(groups.length, 4);
+      const groups = await processor.process(content);
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+      assert.equal(groups.length, 4);
 
-        let group = groups[0];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 1);
-        assert.equal(group.lines[0].text, '');
-        assert.equal(group.lines[0].beforeNumber, FILE);
-        assert.equal(group.lines[0].afterNumber, FILE);
+      let group = groups[0];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 1);
+      assert.equal(group.lines[0].text, '');
+      assert.equal(group.lines[0].beforeNumber, FILE);
+      assert.equal(group.lines[0].afterNumber, FILE);
 
-        group = groups[1];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 2);
+      group = groups[1];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 2);
 
-        function beforeNumberFn(l: GrDiffLine) {
-          return l.beforeNumber;
-        }
-        function afterNumberFn(l: GrDiffLine) {
-          return l.afterNumber;
-        }
-        function textFn(l: GrDiffLine) {
-          return l.text;
-        }
+      function beforeNumberFn(l: GrDiffLine) {
+        return l.beforeNumber;
+      }
+      function afterNumberFn(l: GrDiffLine) {
+        return l.afterNumber;
+      }
+      function textFn(l: GrDiffLine) {
+        return l.text;
+      }
 
-        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(textFn), [
-          '<!DOCTYPE html>',
-          '<meta charset="utf-8">',
-        ]);
+      assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(textFn), [
+        '<!DOCTYPE html>',
+        '<meta charset="utf-8">',
+      ]);
 
-        group = groups[2];
-        assert.equal(group.type, GrDiffGroupType.DELTA);
-        assert.equal(group.lines.length, 3);
-        assert.equal(group.adds.length, 1);
-        assert.equal(group.removes.length, 2);
-        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-        assert.deepEqual(group.removes.map(textFn), [
-          '  Welcome ',
-          '  to the wooorld of tomorrow!',
-        ]);
-        assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
+      group = groups[2];
+      assert.equal(group.type, GrDiffGroupType.DELTA);
+      assert.equal(group.lines.length, 3);
+      assert.equal(group.adds.length, 1);
+      assert.equal(group.removes.length, 2);
+      assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+      assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+      assert.deepEqual(group.removes.map(textFn), [
+        '  Welcome ',
+        '  to the wooorld of tomorrow!',
+      ]);
+      assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
 
-        group = groups[3];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 3);
-        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-        assert.deepEqual(group.lines.map(textFn), [
-          'Leela: This is the only place the ship can’t hear us, so ',
-          'everyone pretend to shower.',
-          'Fry: Same as every day. Got it.',
-        ]);
-      });
+      group = groups[3];
+      assert.equal(group.type, GrDiffGroupType.BOTH);
+      assert.equal(group.lines.length, 3);
+      assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+      assert.deepEqual(group.lines.map(textFn), [
+        'Leela: This is the only place the ship can’t hear us, so ',
+        'everyone pretend to shower.',
+        'Fry: Same as every day. Got it.',
+      ]);
     });
 
-    test('first group is for file', () => {
+    test('first group is for file', async () => {
       const content = [{b: ['foo']}];
 
-      return processor.process(content).then(() => {
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
+      const groups = await processor.process(content);
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-        assert.equal(groups[0].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[0].lines.length, 1);
-        assert.equal(groups[0].lines[0].text, '');
-        assert.equal(groups[0].lines[0].beforeNumber, FILE);
-        assert.equal(groups[0].lines[0].afterNumber, FILE);
-      });
+      assert.equal(groups[0].type, GrDiffGroupType.BOTH);
+      assert.equal(groups[0].lines.length, 1);
+      assert.equal(groups[0].lines[0].text, '');
+      assert.equal(groups[0].lines[0].beforeNumber, FILE);
+      assert.equal(groups[0].lines[0].afterNumber, FILE);
     });
 
-    suite('context groups', () => {
-      test('at the beginning, larger than context', () => {
+    suite('context groups', async () => {
+      test('at the beginning, larger than context', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 100}).fill(
@@ -152,28 +135,27 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return processor.process(content).then(() => {
-          // group[0] is the LOST group
-          // group[1] is the FILE group
+        const groups = await processor.process(content);
+        // group[0] is the LOST group
+        // group[1] is the FILE group
 
-          assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[2].contextGroups[0].lines.length, 90);
-          for (const l of groups[2].contextGroups[0].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
+        assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[2].contextGroups[0].lines.length, 90);
+        for (const l of groups[2].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
 
-          assert.equal(groups[3].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[3].lines.length, 10);
-          for (const l of groups[3].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
+        assert.equal(groups[3].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[3].lines.length, 10);
+        for (const l of groups[3].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
       });
 
       test('at the beginning with skip chunks', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 20}).fill(
@@ -185,8 +167,7 @@
           {a: ['some other content']},
         ];
 
-        await processor.process(content);
-
+        const groups = await processor.process(content);
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
@@ -224,9 +205,9 @@
         }
       });
 
-      test('at the beginning, smaller than context', () => {
+      test('at the beginning, smaller than context', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 5}).fill(
@@ -236,22 +217,21 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
+        // group[0] is the file group
 
-          assert.equal(groups[1].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[1].lines.length, 5);
-          for (const l of groups[1].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
+        assert.equal(groups[1].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[1].lines.length, 5);
+        for (const l of groups[1].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
       });
 
-      test('at the end, larger than context', () => {
+      test('at the end, larger than context', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -261,28 +241,27 @@
           },
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // group[0] is the file group
+        // group[1] is the "a" group
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].contextGroups[0].lines.length, 90);
-          for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-        });
+        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[3].contextGroups[0].lines.length, 90);
+        for (const l of groups[3].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
       });
 
-      test('at the end, smaller than context', () => {
+      test('at the end, smaller than context', async () => {
         options.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
@@ -293,23 +272,22 @@
           },
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // group[0] is the file group
+        // group[1] is the "a" group
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-        });
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 5);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
       });
 
-      test('for interleaved ab and common: true chunks', () => {
+      test('for interleaved ab and common: true chunks', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -347,85 +325,75 @@
           },
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // group[0] is the file group
+        // group[1] is the "a" group
 
-          // The first three interleaved chunks are completely shown because
-          // they are part of the context (3 * 3 <= 10)
+        // The first three interleaved chunks are completely shown because
+        // they are part of the context (3 * 3 <= 10)
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 3);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 3);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[3].type, GrDiffGroupType.DELTA);
-          assert.equal(groups[3].lines.length, 6);
-          assert.equal(groups[3].adds.length, 3);
-          assert.equal(groups[3].removes.length, 3);
-          for (const l of groups[3].removes) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[3].adds) {
-            assert.equal(
-              l.text,
-              '  all work and no play make jill a dull girl'
-            );
-          }
+        assert.equal(groups[3].type, GrDiffGroupType.DELTA);
+        assert.equal(groups[3].lines.length, 6);
+        assert.equal(groups[3].adds.length, 3);
+        assert.equal(groups[3].removes.length, 3);
+        for (const l of groups[3].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[3].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[4].lines.length, 3);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
+        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[4].lines.length, 3);
+        for (const l of groups[4].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
 
-          // The next chunk is partially shown, so it results in two groups
+        // The next chunk is partially shown, so it results in two groups
 
-          assert.equal(groups[5].type, GrDiffGroupType.DELTA);
-          assert.equal(groups[5].lines.length, 2);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].removes.length, 1);
-          for (const l of groups[5].removes) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[5].adds) {
-            assert.equal(
-              l.text,
-              '  all work and no play make jill a dull girl'
-            );
-          }
+        assert.equal(groups[5].type, GrDiffGroupType.DELTA);
+        assert.equal(groups[5].lines.length, 2);
+        assert.equal(groups[5].adds.length, 1);
+        assert.equal(groups[5].removes.length, 1);
+        for (const l of groups[5].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[5].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.equal(groups[6].contextGroups.length, 2);
+        assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.equal(groups[6].contextGroups.length, 2);
 
-          assert.equal(groups[6].contextGroups[0].lines.length, 4);
-          assert.equal(groups[6].contextGroups[0].removes.length, 2);
-          assert.equal(groups[6].contextGroups[0].adds.length, 2);
-          for (const l of groups[6].contextGroups[0].removes) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[6].contextGroups[0].adds) {
-            assert.equal(
-              l.text,
-              '  all work and no play make jill a dull girl'
-            );
-          }
+        assert.equal(groups[6].contextGroups[0].lines.length, 4);
+        assert.equal(groups[6].contextGroups[0].removes.length, 2);
+        assert.equal(groups[6].contextGroups[0].adds.length, 2);
+        for (const l of groups[6].contextGroups[0].removes) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
+        for (const l of groups[6].contextGroups[0].adds) {
+          assert.equal(l.text, '  all work and no play make jill a dull girl');
+        }
 
-          // The final chunk is completely hidden
-          assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[6].contextGroups[1].lines.length, 3);
-          for (const l of groups[6].contextGroups[1].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-        });
+        // The final chunk is completely hidden
+        assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[6].contextGroups[1].lines.length, 3);
+        for (const l of groups[6].contextGroups[1].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
       });
 
-      test('in the middle, larger than context', () => {
+      test('in the middle, larger than context', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -436,36 +404,35 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // group[0] is the file group
+        // group[1] is the "a" group
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].contextGroups[0].lines.length, 80);
-          for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
+        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[3].contextGroups[0].lines.length, 80);
+        for (const l of groups[3].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
 
-          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[4].lines.length, 10);
-          for (const l of groups[4].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-        });
+        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[4].lines.length, 10);
+        for (const l of groups[4].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
       });
 
-      test('in the middle, smaller than context', () => {
+      test('in the middle, smaller than context', async () => {
         options.context = 10;
-        processor = new GrDiffProcessor(consumer, options);
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -476,24 +443,23 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return processor.process(content).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // group[0] is the file group
+        // group[1] is the "a" group
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jill a dull girl');
-          }
-        });
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 5);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'all work and no play make jill a dull girl');
+        }
       });
     });
 
     test('in the middle with skip chunks', async () => {
       options.context = 10;
-      processor = new GrDiffProcessor(consumer, options);
+      processor = new GrDiffProcessor(options);
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
         {
@@ -510,8 +476,7 @@
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
-      await processor.process(content);
-
+      const groups = await processor.process(content);
       groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
@@ -547,7 +512,7 @@
 
     test('works with skip === 0', async () => {
       options.context = 3;
-      processor = new GrDiffProcessor(consumer, options);
+      processor = new GrDiffProcessor(options);
       const content = [
         {
           skip: 0,
@@ -571,7 +536,7 @@
         left: {1: true},
         right: {10: true},
       };
-      processor = new GrDiffProcessor(consumer, options);
+      processor = new GrDiffProcessor(options);
 
       const content = [
         {
@@ -620,7 +585,7 @@
         .fill(0)
         .map(() => `${Math.random()}`);
       const content = [{ab}];
-      processor.context = -1;
+      processor.context = FULL_CONTEXT;
       const result = processor.splitLargeChunks(content);
       assert.equal(result.length, 2);
       assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
@@ -802,27 +767,20 @@
       ]);
     });
 
-    test('isScrolling paused', () => {
+    test('isScrolling paused', async () => {
       const content = Array(200).fill({ab: ['', '']});
       processor.isScrolling = true;
-      processor.process(content);
-      // Just the FILE and LOST groups.
-      assert.equal(groups.length, 2);
-    });
-
-    test('isScrolling unpaused', () => {
-      const content = Array(200).fill({ab: ['', '']});
+      const promise = processor.process(content);
       processor.isScrolling = false;
-      processor.process(content);
-      // More groups have been processed. How many does not matter here.
+      const groups = await promise;
       assert.isAtLeast(groups.length, 3);
     });
 
-    test('image diffs', () => {
+    test('image diffs', async () => {
       const content = Array(200).fill({ab: ['', '']});
       options.isBinary = true;
-      processor = new GrDiffProcessor(consumer, options);
-      processor.process(content);
+      processor = new GrDiffProcessor(options);
+      const groups = await processor.process(content);
       assert.equal(groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
@@ -836,8 +794,8 @@
         rows = loremIpsum.split(' ');
       });
 
-      test('WHOLE_FILE', () => {
-        processor.context = WHOLE_FILE;
+      test('FULL_CONTEXT', () => {
+        processor.context = FULL_CONTEXT;
         const state: State = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
@@ -870,8 +828,8 @@
         );
       });
 
-      test('WHOLE_FILE with skip chunks still get collapsed', () => {
-        processor.context = WHOLE_FILE;
+      test('FULL_CONTEXT with skip chunks still get collapsed', () => {
+        processor.context = FULL_CONTEXT;
         const lineNums = {left: 10, right: 100};
         const state = {
           lineNums,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index f216e04..9cc6a90 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -123,6 +123,7 @@
   test('asks for text for left side Elements', () => {
     const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
     assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
   });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 771e298..f406215 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -6,9 +6,7 @@
 import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
 import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
 import {assertIsDefined, assert} from '../../../utils/common-util';
-import {untilRendered} from '../../../utils/dom-util';
 import {isDefined} from '../../../types/types';
-import {LitElement} from 'lit';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -318,11 +316,6 @@
    */
   readonly keyLocation: boolean = false;
 
-  /**
-   * Once rendered the diff builder sets this to the diff section element.
-   */
-  element?: HTMLElement;
-
   readonly lines: GrDiffLine[] = [];
 
   readonly adds: GrDiffLine[] = [];
@@ -490,22 +483,6 @@
     }
   }
 
-  async waitUntilRendered() {
-    const lineNumber = this.lines[0]?.beforeNumber;
-    // The LOST or FILE lines may be hidden and thus never resolve an
-    // untilRendered() promise.
-    if (
-      this.skip !== undefined ||
-      typeof lineNumber !== 'number' ||
-      this.type === GrDiffGroupType.CONTEXT_CONTROL
-    ) {
-      return Promise.resolve();
-    }
-    assertIsDefined(this.element);
-    await (this.element as LitElement).updateComplete;
-    await untilRendered(this.element.firstElementChild as HTMLElement);
-  }
-
   /**
    * Determines whether the group is either totally an addition or totally
    * a removal.
@@ -517,4 +494,10 @@
       !(!this.adds.length && !this.removes.length)
     );
   }
+
+  id() {
+    return `${this.type} ${this.startLine(Side.LEFT)}  ${this.startLine(
+      Side.RIGHT
+    )}`;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index d309556..cfb64b9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -4,8 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {BlameInfo, CommentRange} from '../../../types/common';
-import {Side} from '../../../constants/constants';
+import {Side, SpecialFilePath} from '../../../constants/constants';
 import {
+  DiffContextExpandedExternalDetail,
   DiffPreferencesInfo,
   DiffResponsiveMode,
   DisplayLine,
@@ -15,6 +16,7 @@
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
+import {GrDiffGroup} from './gr-diff-group';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -188,6 +190,59 @@
   right: {[key: string]: boolean};
 }
 
+/**
+ * "Context" is the number of lines that we are showing around diff chunks and
+ * commented lines. This typically comes from a user preference and is set to
+ * something like 3 or 10.
+ *
+ * `FULL_CONTEXT` means that the user wants to see the entire file. We could
+ * also call this "infinite context".
+ */
+export const FULL_CONTEXT = -1;
+
+export enum FullContext {
+  /** User has opted into showing the full context. */
+  YES = 'YES',
+  /** User has opted into showing only limited context. */
+  NO = 'NO',
+  /**
+   * User has not decided yet. Will see a warning message with two options then,
+   * if the file is too large.
+   */
+  UNDECIDED = 'UNDECIDED',
+}
+
+export function computeContext(
+  prefsContext: number | undefined,
+  showFullContext: FullContext,
+  defaultContext: number
+) {
+  if (showFullContext === FullContext.YES) {
+    return FULL_CONTEXT;
+  }
+  if (
+    prefsContext &&
+    !(showFullContext === FullContext.NO && prefsContext === FULL_CONTEXT)
+  ) {
+    return prefsContext;
+  }
+  return defaultContext;
+}
+
+export function computeLineLength(
+  prefs: DiffPreferencesInfo,
+  path: string | undefined
+): number {
+  if (path === SpecialFilePath.COMMIT_MESSAGE) {
+    return 72;
+  }
+  const lineLength = prefs.line_length;
+  if (Number.isInteger(lineLength) && lineLength > 0) {
+    return lineLength;
+  }
+  return 100;
+}
+
 export function computeKeyLocations(
   lineOfInterest: DisplayLine | undefined,
   comments: GrDiffCommentThread[]
@@ -435,3 +490,11 @@
 
   return blameNode;
 }
+
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
+  /** The context control group that should be replaced by `groups`. */
+  contextGroup: GrDiffGroup;
+  groups: GrDiffGroup[];
+  numLines: number;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 7e6e7fc..639b1ac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -15,8 +15,13 @@
   toCommentThreadModel,
   compareComments,
   GrDiffThreadElement,
+  computeContext,
+  FULL_CONTEXT,
+  FullContext,
+  computeLineLength,
 } from './gr-diff-utils';
 import {FILE, LOST, Side} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
 
 const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
@@ -184,6 +189,55 @@
     assert.isUndefined(getRange(threadEl));
   });
 
+  suite('computeContext', () => {
+    test('computeContext 1', () => {
+      assert.equal(computeContext(1, FullContext.YES, 2), FULL_CONTEXT);
+      assert.equal(computeContext(1, FullContext.NO, 2), 1);
+      assert.equal(computeContext(1, FullContext.UNDECIDED, 2), 1);
+    });
+
+    test('computeContext FULL_CONTEXT', () => {
+      assert.equal(
+        computeContext(FULL_CONTEXT, FullContext.YES, 2),
+        FULL_CONTEXT
+      );
+      assert.equal(computeContext(FULL_CONTEXT, FullContext.NO, 2), 2);
+      assert.equal(
+        computeContext(FULL_CONTEXT, FullContext.UNDECIDED, 2),
+        FULL_CONTEXT
+      );
+    });
+  });
+
+  suite('computeLineLength', () => {
+    test('computeLineLength(1, ...)', () => {
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          'a.txt'
+        ),
+        1
+      );
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          undefined
+        ),
+        1
+      );
+    });
+
+    test('computeLineLength(1, "/COMMIT_MSG")', () => {
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          '/COMMIT_MSG'
+        ),
+        72
+      );
+    });
+  });
+
   suite('key locations', () => {
     test('lineOfInterest is a key location', () => {
       const lineOfInterest = {lineNum: 789, side: Side.LEFT};
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 1f212b0..2834f08 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -11,6 +11,9 @@
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
+import '../gr-diff-builder/gr-diff-builder-image';
+import '../gr-diff-builder/gr-diff-section';
+import '../gr-diff-builder/gr-diff-row';
 import {
   getLine,
   getLineElByChild,
@@ -27,7 +30,9 @@
   getSideByLineEl,
   compareComments,
   toCommentThreadModel,
-  KeyLocations,
+  FullContext,
+  diffClasses,
+  DiffContextExpandedEventDetail,
 } from '../gr-diff/gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -41,14 +46,10 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {
-  createDefaultDiffPrefs,
   DiffViewMode,
   Side,
+  createDefaultDiffPrefs,
 } from '../../../constants/constants';
-import {
-  GrDiffProcessor,
-  ProcessingOptions,
-} from '../gr-diff-processor/gr-diff-processor';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
@@ -59,16 +60,12 @@
   DisplayLine,
   LineNumber,
   LOST,
+  ContentLoadNeededEventDetail,
 } from '../../../api/diff';
 import {isHtmlElement, isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {
-  debounceP,
-  DelayedPromise,
-  DELAYED_CANCELLATION,
-} from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
-import {property, query, state} from 'lit/decorators.js';
+import {property, query, queryAll, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {html, LitElement, nothing, PropertyValues} from 'lit';
 import {when} from 'lit/directives/when.js';
@@ -83,14 +80,9 @@
 import {getDiffLength} from '../../../utils/diff-util';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
 import {
-  GrDiffBuilder,
-  isImageDiffBuilder,
-  isBinaryDiffBuilder,
-  DiffContextExpandedEventDetail,
-} from '../gr-diff-builder/gr-diff-builder';
-import {GrDiffBuilderBinary} from '../gr-diff-builder/gr-diff-builder-binary';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+  GrAnnotationImpl,
+  getStringLength,
+} from '../gr-diff-highlight/gr-annotation';
 import {
   GrDiffGroup,
   GrDiffGroupType,
@@ -98,6 +90,9 @@
 } from './gr-diff-group';
 import {GrDiffLine} from './gr-diff-line';
 import {subscribe} from '../../../elements/lit/subscription-controller';
+import {GrDiffSection} from '../gr-diff-builder/gr-diff-section';
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
+import {repeat} from 'lit/directives/repeat.js';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
@@ -154,6 +149,9 @@
   @query('#diffTable')
   diffTable?: HTMLTableElement;
 
+  @queryAll('gr-diff-section')
+  diffSections?: NodeListOf<GrDiffSection>;
+
   @property({type: Boolean})
   noAutoRender = false;
 
@@ -236,21 +234,6 @@
   @property({type: Boolean})
   override isContentEditable = isSafari();
 
-  /**
-   * Whether the safety check for large diffs when whole-file is set has
-   * been bypassed. If the value is null, then the safety has not been
-   * bypassed. If the value is a number, then that number represents the
-   * context preference to use when rendering the bypassed diff.
-   *
-   * Private but used in tests.
-   */
-  @state()
-  safetyBypass: number | null = null;
-
-  // Private but used in tests.
-  @state()
-  showWarning?: boolean;
-
   @property({type: String})
   errorMessage: string | null = null;
 
@@ -270,59 +253,50 @@
   @state()
   diffLength?: number;
 
-  /**
-   * Observes comment nodes added or removed at any point.
-   * Can be used to unregister upon detachment.
-   */
+  /** Observes comment nodes added or removed at any point. */
   private nodeObserver?: MutationObserver;
 
-  @property({type: Array})
-  layers?: DiffLayer[];
-
-  // Private but used in tests.
-  renderDiffTableTask?: DelayedPromise<void>;
-
   // Private but used in tests.
   diffSelection = new GrDiffSelection();
 
   // Private but used in tests.
   highlights = new GrDiffHighlight();
 
-  private diffModel = new DiffModel(undefined);
-
-  // visible for testing
-  builder?: GrDiffBuilder;
+  private diffModel = new DiffModel();
 
   /**
-   * All layers, both from the outside and the default ones. See `layers` for
-   * the property that can be set from the outside.
+   * Just the layers that are passed in from the outside. See `layersAll`
+   * for an array of all layers.
    */
-  // visible for testing
-  layersInternal: DiffLayer[] = [];
+  @property({type: Array})
+  layers: DiffLayer[] = [];
 
-  // visible for testing
-  showTabs?: boolean;
+  /**
+   * Just the internal default layers. See `layers` for the property that can
+   * be set from the outside.
+   */
+  @state() layersInternal: DiffLayer[] = [];
 
-  // visible for testing
-  showTrailingWhitespace?: boolean;
+  /**
+   * All layers, just combines `layers` and `layersInternal`.
+   */
+  @state() layersAll: DiffLayer[] = [];
 
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
 
-  private rangeLayer?: GrRangedCommentLayer;
+  private rangeLayer = new GrRangedCommentLayer();
 
-  // visible for testing
-  processor?: GrDiffProcessor;
+  @state() groups: GrDiffGroup[] = [];
 
-  /**
-   * Groups are mostly just passed on to the diff builder (this.builder). But
-   * we also keep track of them here for being able to fire a `render-content`
-   * event when .element of each group has rendered.
-   */
-  private groups: GrDiffGroup[] = [];
+  @state() private context = 3;
 
-  private keyLocations: KeyLocations = {left: {}, right: {}};
+  private readonly layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
 
   static override get styles() {
     return [
@@ -339,18 +313,32 @@
     provide(this, diffModelToken, () => this.diffModel);
     subscribe(
       this,
-      () => this.diffModel.keyLocations$,
-      keyLocations => (this.keyLocations = keyLocations)
+      () => this.diffModel.context$,
+      context => (this.context = context)
+    );
+    subscribe(
+      this,
+      () => this.diffModel.groups$,
+      groups => (this.groups = groups)
     );
     this.addEventListener(
       'create-range-comment',
       (e: CustomEvent<CreateRangeCommentEventDetail>) =>
         this.handleCreateRangeComment(e)
     );
-    this.addEventListener('render-content', () => this.handleRenderContent());
     this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
       this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
     });
+    this.addEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+    this.layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this.requestRowUpdates(start, end, side);
+    this.layersInternalInit();
   }
 
   override connectedCallback() {
@@ -364,15 +352,12 @@
     if (this.diffTable) {
       this.highlights.init(this.diffTable, this);
     }
-    this.diffBuilderInit();
   }
 
   override disconnectedCallback() {
     this.removeSelectionListeners();
-    this.renderDiffTableTask?.cancel();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
-    this.diffBuilderCleanup();
     super.disconnectedCallback();
   }
 
@@ -381,16 +366,24 @@
       changedProperties.has('diff') ||
       changedProperties.has('path') ||
       changedProperties.has('renderPrefs') ||
+      changedProperties.has('viewMode') ||
       changedProperties.has('prefs') ||
       changedProperties.has('lineOfInterest')
     ) {
-      this.diffModel.updateState({
-        diff: this.diff,
-        path: this.path,
-        renderPrefs: this.renderPrefs,
-        diffPrefs: this.prefs,
-        lineOfInterest: this.lineOfInterest,
-      });
+      if (this.diff && this.prefs) {
+        const renderPrefs = {...(this.renderPrefs ?? {})};
+        if (renderPrefs.view_mode === undefined) {
+          renderPrefs.view_mode = this.viewMode;
+        }
+        this.diffModel.updateState({
+          diff: this.diff,
+          path: this.path,
+          renderPrefs,
+          diffPrefs: this.prefs,
+          lineOfInterest: this.lineOfInterest,
+          isImageDiff: this.isImageDiff,
+        });
+      }
     }
     if (
       changedProperties.has('path') ||
@@ -401,6 +394,9 @@
     ) {
       this.prefsChanged();
     }
+    if (changedProperties.has('layers')) {
+      this.layersChanged();
+    }
     if (changedProperties.has('blame')) {
       this.blameChanged();
     }
@@ -422,18 +418,37 @@
     }
   }
 
-  protected override updated(changedProperties: PropertyValues<this>): void {
+  private async fireRenderContent() {
+    await this.updateComplete;
+    this.loading = false;
+    this.observeNodes();
+    // TODO: Retire one of these two events.
+    fire(this, 'render-content', {});
+    fire(this, 'render', {});
+  }
+
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    const sections = [...(this.diffSections ?? [])];
+    await Promise.all(sections.map(section => section.updateComplete));
+    return result;
+  }
+
+  protected override updated(changedProperties: PropertyValues<this>) {
     if (changedProperties.has('diff')) {
-      // diffChanged relies on diffTable ahving been rendered.
+      // diffChanged relies on diffTable having been rendered.
       this.diffChanged();
     }
+    if (changedProperties.has('groups')) {
+      if (this.groups?.length > 0) this.fireRenderContent();
+    }
   }
 
   override render() {
+    fire(this.diffTable, 'render-start', {});
     return html`
       ${this.renderHeader()} ${this.renderContainer()}
       ${this.renderNewlineWarning()} ${this.renderLoadingError()}
-      ${this.renderSizeWarning()}
     `;
   }
 
@@ -449,6 +464,7 @@
 
   private renderContainer() {
     const cssClasses = {
+      newDiff: true,
       diffContainer: true,
       unified: this.viewMode === DiffViewMode.UNIFIED,
       sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
@@ -460,7 +476,19 @@
           id="diffTable"
           class=${this.diffTableClass}
           ?contenteditable=${this.isContentEditable}
-        ></table>
+        >
+          ${this.renderColumns()}
+          ${when(!this.showWarning(), () =>
+            repeat(
+              this.groups,
+              group => group.id(),
+              group => this.renderSectionElement(group)
+            )
+          )}
+          ${when(this.diff?.binary, () =>
+            this.isImageDiff ? this.renderImageDiff() : this.renderBinaryDiff()
+          )}
+        </table>
         ${when(
           this.showNoChangeMessage(),
           () => html`
@@ -470,6 +498,7 @@
             </div>
           `
         )}
+        ${when(this.showWarning(), () => this.renderSizeWarning())}
       </div>
     `;
   }
@@ -486,7 +515,7 @@
   }
 
   private renderSizeWarning() {
-    if (!this.showWarning) return nothing;
+    if (!this.showWarning()) return nothing;
     // TODO: Update comment about 'Whole file' as it's not in settings.
     return html`
       <div id="sizeWarning">
@@ -597,7 +626,7 @@
       });
     }
 
-    this.updateCommentRanges(this.commentRanges);
+    this.rangeLayer?.updateRanges(this.commentRanges);
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
@@ -613,11 +642,8 @@
     });
   }
 
-  /** Cancel any remaining diff builder rendering work. */
-  cancel() {
-    this.diffBuilderCleanup();
-    this.renderDiffTableTask?.cancel();
-  }
+  /** TODO: Can be removed when diff-old is gone. */
+  cancel() {}
 
   getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
@@ -642,7 +668,7 @@
   }
 
   private blameChanged() {
-    this.setBlame(this.blame);
+    this.setBlame(this.blame ?? []);
     if (this.blame) {
       this.classList.add('showBlame');
     } else {
@@ -760,22 +786,20 @@
     this.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
-  private cleanup() {
-    this.cancel();
-    this.blame = null;
-    this.safetyBypass = null;
-    this.showWarning = false;
-    this.clearDiffContent();
-  }
-
   private prefsChanged() {
     if (!this.prefs) return;
 
     this.blame = null;
     this.updatePreferenceStyles();
 
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this.debounceRenderDiffTable();
+    if (!Number.isInteger(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+      this.handlePreferenceError('tab size');
+    }
+    if (
+      !Number.isInteger(this.prefs.line_length) ||
+      this.prefs.line_length <= 0
+    ) {
+      this.handlePreferenceError('diff width');
     }
   }
 
@@ -855,15 +879,12 @@
     if (this.prefs) {
       this.updatePreferenceStyles();
     }
-    this.updateRenderPrefs(this.renderPrefs);
   }
 
   private diffChanged() {
     this.loading = true;
-    this.cleanup();
     if (this.diff) {
       this.diffLength = this.getDiffLength(this.diff);
-      this.debounceRenderDiffTable();
       assertIsDefined(this.diffTable, 'diffTable');
       this.diffSelection.init(this.diff, this.diffTable);
       this.highlights.init(this.diffTable, this);
@@ -875,73 +896,23 @@
     return getDiffLength(diff);
   }
 
-  /**
-   * When called multiple times from the same task, will call
-   * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
-   *
-   * This should be used instead of calling _renderDiffTable directly to
-   * render the diff in response to an input change, because there may be
-   * multiple inputs changing in the same microtask, but we only want to
-   * render once.
-   */
-  private debounceRenderDiffTable() {
-    // at this point gr-diff might be considered as rendered from the outside
-    // (client), although it was not actually rendered. Clients need to know
-    // when it is safe to perform operations like cursor moves, for example,
-    // and if changing an input actually requires a reload of the diff table.
-    // Since `fire` is synchronous it allows clients to be aware when an
-    // async render is needed and that they can wait for a further `render`
-    // event to actually take further action.
-    fire(this, 'render-required', {});
-    this.renderDiffTableTask = debounceP(
-      this.renderDiffTableTask,
-      async () => await this.renderDiffTable()
-    );
-    this.renderDiffTableTask.catch((e: unknown) => {
-      if (e === DELAYED_CANCELLATION) return;
-      throw e;
-    });
-  }
-
-  // Private but used in tests.
-  async renderDiffTable() {
-    this.unobserveNodes();
-    if (!this.diff || !this.prefs) {
-      fire(this, 'render', {});
-      return;
-    }
-    if (
-      this.getBypassPrefs().context === -1 &&
+  private showWarning() {
+    return (
+      this.prefs?.context === FULL_CONTEXT &&
+      this.diffModel.getState().showFullContext === FullContext.UNDECIDED &&
       this.diffLength &&
-      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-      this.safetyBypass === null
-    ) {
-      this.showWarning = true;
-      fire(this, 'render', {});
-      return;
-    }
-
-    this.showWarning = false;
-
-    this.updateCommentRanges(this.commentRanges);
-    this.updateCoverageRanges(this.coverageRanges);
-    await this.legacyRender();
-  }
-
-  private handleRenderContent() {
-    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
-      element.remove()
+      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES
     );
-    this.loading = false;
-    this.observeNodes();
-    // We are just converting 'render-content' into 'render' here. Maybe we
-    // should retire the 'render' event in favor of 'render-content'?
-    fire(this, 'render', {});
   }
 
+  /**
+   * This must be called once, but only after diff lines are rendered. Otherwise
+   * `processNodes()` will fail to lookup the HTML elements that it wants to
+   * manipulate.
+   */
   private observeNodes() {
+    if (this.nodeObserver) return;
     // First stop observing old nodes.
-    this.unobserveNodes();
     // Then introduce a Mutation observer that watches for children being added
     // to gr-diff. If those children are `isThreadEl`, namely then they are
     // processed.
@@ -1020,19 +991,6 @@
     }
   }
 
-  private unobserveNodes() {
-    if (this.nodeObserver) {
-      this.nodeObserver.disconnect();
-      this.nodeObserver = undefined;
-    }
-    // You only stop observing for comment thread elements when the diff is
-    // completely rendered from scratch. And then comment thread elements
-    // will be (re-)added *after* rendering is done. That is also when we
-    // re-start observing. So it is appropriate to thoroughly clean up
-    // everything that the observer is managing.
-    this.commentRanges = [];
-  }
-
   private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
     const existingMessage = lostCell.querySelector('div.lost-message');
     if (existingMessage) return;
@@ -1048,23 +1006,8 @@
     lostCell.insertBefore(div, lostCell.firstChild);
   }
 
-  /**
-   * Get the preferences object including the safety bypass context (if any).
-   */
-  // visible for testing
-  getBypassPrefs() {
-    assertIsDefined(this.prefs, 'prefs');
-    if (this.safetyBypass !== null) {
-      return {...this.prefs, context: this.safetyBypass};
-    }
-    return this.prefs;
-  }
-
-  clearDiffContent() {
-    this.unobserveNodes();
-    if (!this.diffTable) return;
-    this.diffTable.innerHTML = '';
-  }
+  /** TODO: Can be removed when diff-old is gone. */
+  clearDiffContent() {}
 
   // Private but used in tests.
   computeDiffHeaderItems() {
@@ -1083,28 +1026,20 @@
   }
 
   private handleFullBypass() {
-    this.safetyBypass = FULL_CONTEXT;
-    this.debounceRenderDiffTable();
+    this.diffModel.updateState({showFullContext: FullContext.YES});
   }
 
   private collapseContext() {
-    // Uses the default context amount if the preference is for the entire file.
-    this.safetyBypass =
-      this.prefs?.context && this.prefs.context >= 0
-        ? null
-        : createDefaultDiffPrefs().context;
-    this.debounceRenderDiffTable();
+    this.diffModel.updateState({showFullContext: FullContext.NO});
   }
 
+  // TODO: Migrate callers to just update prefs.context.
   toggleAllContext() {
-    if (!this.prefs) {
-      return;
-    }
-    if (this.getBypassPrefs().context < 0) {
-      this.collapseContext();
-    } else {
-      this.handleFullBypass();
-    }
+    const current = this.diffModel.getState().showFullContext;
+    this.diffModel.updateState({
+      showFullContext:
+        current === FullContext.YES ? FullContext.NO : FullContext.YES,
+    });
   }
 
   private computeNewlineWarning(): string | undefined {
@@ -1121,72 +1056,49 @@
     return messages.join(' \u2014 '); // \u2014 - '—'
   }
 
-  private updateCommentRanges(ranges: CommentRangeLayer[]) {
-    this.rangeLayer?.updateRanges(ranges);
-  }
-
   private updateCoverageRanges(rs: CoverageRange[]) {
     this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
     this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
-  legacyRender(): Promise<void> {
-    assertIsDefined(this.diff, 'diff');
-    assertIsDefined(this.diffTable, 'diff table');
-    assertIsDefined(this.prefs, 'prefs');
-
-    // Setting up annotation layers must happen after plugins are
-    // 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.showTabs = this.prefs.show_tabs;
-    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
-
-    this.diffBuilderCleanup();
-    this.builder = this.getDiffBuilder();
-    this.diffBuilderInit();
-
-    this.diffTable.innerHTML = '';
-    this.builder.addColumns(this.diffTable, getLineNumberCellWidth(this.prefs));
-
-    const options: ProcessingOptions = {
-      context: this.getBypassPrefs().context,
-      keyLocations: this.keyLocations,
-      isBinary: !!(this.isImageDiff || this.diff.binary),
-    };
-    if (this.renderPrefs?.num_lines_rendered_at_once) {
-      options.asyncThreshold = this.renderPrefs.num_lines_rendered_at_once;
-    }
-    this.processor = new GrDiffProcessor(this, options);
-
-    fire(this.diffTable, 'render-start', {});
-    return (
-      this.processor
-        .process(this.diff.content)
-        .then(async () => {
-          if (isImageDiffBuilder(this.builder)) {
-            this.builder.renderImageDiff();
-          } else if (isBinaryDiffBuilder(this.builder)) {
-            this.builder.renderBinaryDiff();
-          }
-          await this.untilGroupsRendered();
-          fire(this.diffTable, 'render-content', {});
-        })
-        // Mocha testing does not like uncaught rejections, so we catch
-        // the cancels which are expected and should not throw errors in
-        // tests.
-        .catch(e => {
-          if (!e.isCanceled) return Promise.reject(e);
-          return;
-        })
+  public renderImageDiff() {
+    return when(
+      this.useNewImageDiffUi,
+      () => this.renderImageDiffNew(),
+      () => this.renderImageDiffOld()
     );
   }
 
-  // visible for testing
-  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
-    return Promise.all(groups.map(g => g.waitUntilRendered()));
+  private renderImageDiffNew() {
+    const autoBlink = !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+    return html`
+      <gr-diff-image-new
+        .automaticBlink=${autoBlink}
+        .baseImage=${this.baseImage ?? undefined}
+        .revisionImage=${this.revisionImage ?? undefined}
+      ></gr-diff-image-new>
+    `;
+  }
+
+  private renderImageDiffOld() {
+    return html`
+      <gr-diff-image-old
+        .baseImage=${this.baseImage ?? undefined}
+        .revisionImage=${this.revisionImage ?? undefined}
+      ></gr-diff-image-old>
+    `;
+  }
+
+  public renderBinaryDiff() {
+    return html`
+      <tbody class="gr-diff binary-diff">
+        <tr class="gr-diff">
+          <td colspan="5" class="gr-diff">
+            <span>Difference in binary files</span>
+          </td>
+        </tr>
+      </tbody>
+    `;
   }
 
   private onDiffContextExpanded = (
@@ -1194,14 +1106,19 @@
   ) => {
     // Don't stop propagation. The host may listen for reporting or
     // resizing.
-    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+    this.diffModel.replaceGroup(e.detail.contextGroup, e.detail.groups);
   };
 
-  // visible for testing
-  setupAnnotationLayers() {
-    this.rangeLayer = new GrRangedCommentLayer();
+  private layersChanged() {
+    this.layersAll = [...this.layersInternal, ...this.layers];
+    for (const layer of this.layersAll) {
+      layer.removeListener?.(this.layerUpdateListener);
+      layer.addListener?.(this.layerUpdateListener);
+    }
+  }
 
-    const layers: DiffLayer[] = [
+  private layersInternalInit() {
+    this.layersInternal = [
       this.createTrailingWhitespaceLayer(),
       this.createIntralineLayer(),
       this.createTabIndicatorLayer(),
@@ -1210,16 +1127,7 @@
       this.coverageLayerLeft,
       this.coverageLayerRight,
     ];
-
-    if (this.layers) {
-      layers.push(...this.layers);
-    }
-    this.layersInternal = layers;
-  }
-
-  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return undefined;
-    return this.builder.getContentTdByLine(lineNumber, side);
+    this.layersChanged();
   }
 
   getContentTdByLineEl(lineEl?: Element): Element | undefined {
@@ -1230,21 +1138,6 @@
     return this.getContentTdByLine(line, side);
   }
 
-  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return undefined;
-    return this.builder.getLineElByNumber(lineNumber, side);
-  }
-
-  getLineNumberRows() {
-    if (!this.builder) return [];
-    return this.builder.getLineNumberRows();
-  }
-
-  getLineNumEls(side: Side) {
-    if (!this.builder) return [];
-    return this.builder.getLineNumEls(side);
-  }
-
   /**
    * When the line is hidden behind a context expander, expand it.
    *
@@ -1255,8 +1148,7 @@
    */
   unhideLine(lineNum: number, side: Side) {
     assertIsDefined(this.prefs, 'prefs');
-    if (!this.builder) return;
-    const group = this.builder.findGroup(side, lineNum);
+    const group = this.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -1267,7 +1159,7 @@
     const groups = hideInContextControl(
       group.contextGroups,
       0,
-      lineOffset - 1 - this.prefs.context
+      lineOffset - 1 - this.context
     );
     // If there is a context group, it will be the first group because we
     // start hiding from 0 offset
@@ -1277,68 +1169,14 @@
     newGroups.push(
       ...hideInContextControl(
         groups,
-        lineOffset + 1 + this.prefs.context,
+        lineOffset + 1 + this.context,
         // Both ends inclusive, so difference is the offset of the last line.
         // But we need to pass the first line not to hide, which is the element
         // after.
         lineRange.end_line - lineRange.start_line + 1
       )
     );
-    this.replaceGroup(group, newGroups);
-  }
-
-  /**
-   * Replace the group of a context control section by rendering the provided
-   * groups instead. This happens in response to expanding a context control
-   * group.
-   *
-   * @param contextGroup The context control group to replace
-   * @param newGroups The groups that are replacing the context control group
-   */
-  private replaceGroup(
-    contextGroup: GrDiffGroup,
-    newGroups: readonly GrDiffGroup[]
-  ) {
-    if (!this.builder) return;
-    fire(this.diffTable, 'render-start', {});
-    this.builder.replaceGroup(contextGroup, newGroups);
-    this.groups = this.groups.filter(g => g !== contextGroup);
-    this.groups.push(...newGroups);
-    this.untilGroupsRendered(newGroups).then(() => {
-      fire(this.diffTable, 'render-content', {});
-    });
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component re-connects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with cleanup(), which is called
-   * when gr-diff disconnects.
-   */
-  private diffBuilderInit() {
-    this.cleanup();
-    this.diffTable?.addEventListener(
-      'diff-context-expanded-internal-new',
-      this.onDiffContextExpanded
-    );
-    this.builder?.init();
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component disconnects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with init(), which is called when
-   * gr-diff re-connects.
-   */
-  private diffBuilderCleanup() {
-    this.processor?.cancel();
-    this.builder?.cleanup();
-    this.diffTable?.removeEventListener(
-      'diff-context-expanded-internal-new',
-      this.onDiffContextExpanded
-    );
+    this.diffModel.replaceGroup(group, newGroups);
   }
 
   // visible for testing
@@ -1346,95 +1184,11 @@
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    assertIsDefined(this.diffTable, 'diff table');
-    fireAlert(this.diffTable, message);
+    fireAlert(this, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
   // visible for testing
-  getDiffBuilder(): GrDiffBuilder {
-    assertIsDefined(this.diff, 'diff');
-    assertIsDefined(this.diffTable, 'diff table');
-    assertIsDefined(this.prefs, 'prefs');
-    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this.handlePreferenceError('tab size');
-    }
-
-    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this.handlePreferenceError('diff width');
-    }
-
-    const localPrefs = {...this.prefs};
-    if (this.path === COMMIT_MSG_PATH) {
-      // override line_length for commit msg the same way as
-      // in gr-diff
-      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
-    }
-
-    let builder = null;
-    if (this.isImageDiff) {
-      builder = new GrDiffBuilderImage(
-        this.diff,
-        localPrefs,
-        this.diffTable,
-        this.baseImage ?? null,
-        this.revisionImage ?? null,
-        this.renderPrefs,
-        this.useNewImageDiffUi
-      );
-    } else if (this.diff.binary) {
-      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffTable);
-    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.renderPrefs = {
-        ...this.renderPrefs,
-        view_mode: DiffViewMode.SIDE_BY_SIDE,
-      };
-      builder = new GrDiffBuilder(
-        this.diff,
-        localPrefs,
-        this.diffTable,
-        this.layersInternal,
-        this.renderPrefs
-      );
-    } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      this.renderPrefs = {
-        ...this.renderPrefs,
-        view_mode: DiffViewMode.UNIFIED,
-      };
-      builder = new GrDiffBuilder(
-        this.diff,
-        localPrefs,
-        this.diffTable,
-        this.layersInternal,
-        this.renderPrefs
-      );
-    }
-    if (!builder) {
-      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
-    }
-    return builder;
-  }
-
-  /**
-   * Called when the processor starts converting the diff information from the
-   * server into chunks.
-   */
-  clearGroups() {
-    if (!this.builder) return;
-    this.groups = [];
-    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]);
-    this.groups.push(group);
-  }
-
-  // visible for testing
   createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
@@ -1453,10 +1207,10 @@
           // If endIndex isn't present, continue to the end of the line.
           const endIndex =
             highlight.endIndex === undefined
-              ? GrAnnotation.getStringLength(line.text)
+              ? getStringLength(line.text)
               : highlight.endIndex;
 
-          GrAnnotation.annotateElement(
+          GrAnnotationImpl.annotateElement(
             contentEl,
             highlight.startIndex,
             endIndex - highlight.startIndex,
@@ -1469,15 +1223,10 @@
 
   // visible for testing
   createTabIndicatorLayer(): DiffLayer {
-    const show = () => this.showTabs;
+    const show = () => this.prefs?.show_tabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        // If visible tabs are disabled, do nothing.
-        if (!show()) {
-          return;
-        }
-
-        // Find and annotate the locations of tabs.
+        if (!show()) return;
         annotateSymbols(contentEl, line, '\t', 'tab-indicator');
       },
     };
@@ -1501,23 +1250,17 @@
 
   // visible for testing
   createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this.showTrailingWhitespace;
-
+    const show = () => this.prefs?.show_whitespace_errors;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        if (!show()) {
-          return;
-        }
-
+        if (!show()) return;
         const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
         if (match) {
           // Normalize string positions in case there is unicode before or
           // within the match.
-          const index = GrAnnotation.getStringLength(
-            line.text.substr(0, match.index)
-          );
-          const length = GrAnnotation.getStringLength(match[0]);
-          GrAnnotation.annotateElement(
+          const index = getStringLength(line.text.substr(0, match.index));
+          const length = getStringLength(match[0]);
+          GrAnnotationImpl.annotateElement(
             contentEl,
             index,
             length,
@@ -1528,13 +1271,167 @@
     };
   }
 
-  setBlame(blame: BlameInfo[] | null) {
-    if (!this.builder) return;
-    this.builder.setBlame(blame ?? []);
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(side, lineNumber);
+    return row?.getContentCell(side);
   }
 
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this.builder?.updateRenderPrefs(renderPrefs);
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(side, lineNumber);
+    return row?.getLineNumberCell(side);
+  }
+
+  private findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    assertIsDefined(this.diffTable, 'diffTable');
+    const sections = [
+      ...this.diffTable.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  /** This is used when layers initiate an update. */
+  private requestRowUpdates(start: LineNumber, end: LineNumber, side: Side) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+    assertIsDefined(this.diffTable, 'diffTable');
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.diffTable.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  renderSectionElement(group: GrDiffGroup) {
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    if (this.diff?.binary && group.startLine(Side.LEFT) === LOST) {
+      return nothing;
+    }
+    return html`
+      <gr-diff-section
+        class="${leftClass} ${rightClass}"
+        .group=${group}
+        .diff=${this.diff}
+        .layers=${this.layersAll}
+        .diffPrefs=${this.prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+  }
+
+  renderColumns() {
+    const lineNumberWidth = getLineNumberCellWidth(
+      this.prefs ?? createDefaultDiffPrefs()
+    );
+    return html`
+      <colgroup>
+        <col class=${diffClasses('blame')}></col>
+        ${when(
+          (this.renderPrefs?.view_mode ?? this.viewMode) ===
+            DiffViewMode.UNIFIED,
+          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+          () => html`
+            ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+            ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+          `
+        )}
+      </colgroup>
+    `;
+  }
+
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
+  findGroup(side: Side, line: LineNumber) {
+    return this.groups.find(group => group.containsLine(side, line));
+  }
+
+  // visible for testing
+  getGroupsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side
+  ): GrDiffGroup[] {
+    const startIndex = this.groups.findIndex(group =>
+      group.containsLine(side, startLine)
+    );
+    if (startIndex === -1) return [];
+    let endIndex = this.groups.findIndex(group =>
+      group.containsLine(side, endLine)
+    );
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
+    if (endIndex === -1) endIndex = this.groups.length - 1;
+    // The filter preserves the legacy behavior to only return non-context
+    // groups
+    return this.groups
+      .slice(startIndex, endIndex + 1)
+      .filter(group => group.lines.length > 0);
+  }
+
+  /**
+   * Set the blame information for the diff. For any already-rendered line,
+   * re-render its blame cell content.
+   */
+  setBlame(blame: BlameInfo[]) {
+    for (const blameInfo of blame) {
+      for (const range of blameInfo.ranges) {
+        for (let line = range.start; line <= range.end; line++) {
+          const row = this.findRow(Side.LEFT, line);
+          if (row) row.blameInfo = blameInfo;
+        }
+      }
+    }
   }
 }
 
@@ -1564,7 +1461,7 @@
     // Skip forward by the length of the content
     pos += split[i].length;
 
-    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+    GrAnnotationImpl.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
 
     pos++;
   }
@@ -1595,5 +1492,7 @@
      * renders and for partial rerenders.
      */
     'render-content': CustomEvent<{}>;
+    'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 99db4e9..30f85cc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -30,7 +30,6 @@
   queryAndAssert,
   stubBaseUrl,
   stubRestApi,
-  waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
@@ -39,12 +38,13 @@
 import {GrDiff} from './gr-diff';
 import {ImageInfo} from '../../../types/common';
 import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
-import {assertIsDefined} from '../../../utils/common-util';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder';
 import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+  GrAnnotationImpl,
+  getStringLength,
+} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine} from './gr-diff-line';
 
 const DEFAULT_PREFS = createDefaultDiffPrefs();
@@ -76,8 +76,18 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer sideBySide">
-            <table id="diffTable"></table>
+          <div class="diffContainer newDiff sideBySide">
+            <table id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left sign" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right sign" />
+                <col class="gr-diff right" />
+              </colgroup>
+            </table>
           </div>
         `
       );
@@ -92,7 +102,7 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer unified">
+          <div class="diffContainer newDiff unified">
             <table class="selected-right" id="diffTable">
               <colgroup>
                 <col class="blame gr-diff" />
@@ -1358,7 +1368,7 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <div class="diffContainer sideBySide">
+          <div class="diffContainer newDiff sideBySide">
             <table class="selected-right" id="diffTable">
               <colgroup>
                 <col class="blame gr-diff" />
@@ -3150,7 +3160,7 @@
         assert.shadowDom.equal(
           element,
           /* HTML */ `
-            <div class="diffContainer sideBySide">
+            <div class="diffContainer newDiff sideBySide">
               <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
               <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
               <table class="selected-right" id="diffTable">
@@ -3163,7 +3173,6 @@
                   <col class="gr-diff right sign" />
                   <col class="gr-diff right" />
                 </colgroup>
-                <tbody class="binary-diff gr-diff"></tbody>
                 <tbody class="both gr-diff section">
                   <tr
                     aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
@@ -3305,13 +3314,13 @@
                 <td class="blank gr-diff left lineNum"></td>
                 <td class="gr-diff left">
                   <label class="gr-diff">
-                    <span class="gr-diff label"> image/bmp </span>
+                    <span class="gr-diff label"> 1×1 image/bmp </span>
                   </label>
                 </td>
                 <td class="blank gr-diff lineNum right"></td>
                 <td class="gr-diff right">
                   <label class="gr-diff">
-                    <span class="gr-diff label"> image/bmp </span>
+                    <span class="gr-diff label"> 1×1 image/bmp </span>
                   </label>
                 </td>
               </tr>
@@ -3369,7 +3378,7 @@
             <label class="gr-diff">
               <span class="gr-diff name"> carrot.jpg </span>
               <br class="gr-diff" />
-              <span class="gr-diff label"> image/bmp </span>
+              <span class="gr-diff label"> 1×1 image/bmp </span>
             </label>
           `
         );
@@ -3379,7 +3388,7 @@
             <label class="gr-diff">
               <span class="gr-diff name"> carrot2.jpg </span>
               <br class="gr-diff" />
-              <span class="gr-diff label"> image/bmp </span>
+              <span class="gr-diff label"> 1×1 image/bmp </span>
             </label>
           `
         );
@@ -3538,7 +3547,6 @@
           ignore_whitespace: 'IGNORE_NONE',
         };
         await element.updateComplete;
-        element.renderDiffTable();
       }
 
       test('returns [] when hidden and noAutoRender', async () => {
@@ -3554,6 +3562,7 @@
       test('returns one stop per line and one for the file row', async () => {
         await setupDiff();
         element.loading = false;
+        await waitUntil(() => element.groups.length > 2);
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
@@ -3567,10 +3576,12 @@
       test('returns an additional AbortStop when still loading', async () => {
         await setupDiff();
         element.loading = true;
+        await waitUntil(() => element.groups.length > 2);
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
         const LOST_ROW = 1;
+        element.loading = true;
         const actual = element.getCursorStops();
         assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
         assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
@@ -3694,67 +3705,6 @@
 
       assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
     });
-
-    suite('change in preferences', () => {
-      setup(async () => {
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        await element.updateComplete;
-        await element.renderDiffTableTask?.flush();
-      });
-
-      test('change in preferences re-renders diff', async () => {
-        const stub = sinon.stub(element, 'renderDiffTable');
-        element.prefs = {
-          ...MINIMAL_PREFS,
-        };
-        await element.updateComplete;
-        await element.renderDiffTableTask?.flush();
-        assert.isTrue(stub.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', async () => {
-        const stub = sinon.stub(element, 'renderDiffTable');
-        const newPrefs1: DiffPreferencesInfo = {
-          ...MINIMAL_PREFS,
-          line_wrapping: true,
-        };
-        element.prefs = newPrefs1;
-        await element.updateComplete;
-        await element.renderDiffTableTask?.flush();
-        assert.isTrue(stub.called);
-        stub.reset();
-
-        const newPrefs2 = {...newPrefs1};
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        await element.updateComplete;
-        await element.renderDiffTableTask?.flush();
-        assert.isTrue(stub.called);
-      });
-
-      test(
-        'change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange',
-        async () => {
-          const stub = sinon.stub(element, 'renderDiffTable');
-          element.noRenderOnPrefsChange = true;
-          element.prefs = {
-            ...MINIMAL_PREFS,
-            context: 12,
-          };
-          await element.updateComplete;
-          await element.renderDiffTableTask?.flush();
-          assert.isFalse(stub.called);
-        }
-      );
-    });
   });
 
   suite('diff header', () => {
@@ -3801,89 +3751,6 @@
     });
   });
 
-  suite('safety and bypass', () => {
-    let renderStub: sinon.SinonStub;
-
-    setup(async () => {
-      renderStub = sinon.stub(element, 'legacyRender').callsFake(() => {
-        assertIsDefined(element.diffTable);
-        element.diffTable.dispatchEvent(
-          new CustomEvent('render', {bubbles: true, composed: true})
-        );
-        return Promise.resolve();
-      });
-      sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = createDiff();
-      element.noRenderOnPrefsChange = true;
-      await element.updateComplete;
-    });
-
-    test('large render w/ context = 10', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 10};
-      element.renderDiffTable();
-      await waitForEventOnce(element, 'render');
-
-      assert.isTrue(renderStub.called);
-      assert.isFalse(element.showWarning);
-    });
-
-    test('large render w/ whole file and bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element.safetyBypass = 10;
-      element.renderDiffTable();
-      await waitForEventOnce(element, 'render');
-
-      assert.isTrue(renderStub.called);
-      assert.isFalse(element.showWarning);
-    });
-
-    test('large render w/ whole file and no bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element.renderDiffTable();
-      await waitForEventOnce(element, 'render');
-
-      assert.isFalse(renderStub.called);
-      assert.isTrue(element.showWarning);
-    });
-
-    test('toggles expand context using bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-
-      element.toggleAllContext();
-      element.renderDiffTable();
-      await element.updateComplete;
-
-      assert.equal(element.prefs.context, 3);
-      assert.equal(element.safetyBypass, -1);
-      assert.equal(element.getBypassPrefs().context, -1);
-    });
-
-    test('toggles collapse context from bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-      element.safetyBypass = -1;
-
-      element.toggleAllContext();
-      element.renderDiffTable();
-      await element.updateComplete;
-
-      assert.equal(element.prefs.context, 3);
-      assert.isNull(element.safetyBypass);
-      assert.equal(element.getBypassPrefs().context, 3);
-    });
-
-    test('toggles collapse context from pref using default', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-
-      element.toggleAllContext();
-      element.renderDiffTable();
-      await element.updateComplete;
-
-      assert.equal(element.prefs.context, -1);
-      assert.equal(element.safetyBypass, 10);
-      assert.equal(element.getBypassPrefs().context, 10);
-    });
-  });
-
   suite('blame', () => {
     test('unsetting', async () => {
       element.blame = [];
@@ -3891,7 +3758,7 @@
       element.classList.add('showBlame');
       element.blame = null;
       await element.updateComplete;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isTrue(setBlameSpy.calledWithExactly([]));
       assert.isFalse(element.classList.contains('showBlame'));
     });
 
@@ -3999,36 +3866,10 @@
       content,
       binary,
     };
+    await waitUntil(() => element.groups.length > 1);
     await element.updateComplete;
-    await element.renderDiffTableTask;
   };
 
-  test('clear diff table content as soon as diff changes', async () => {
-    const content = [
-      {
-        a: ['all work and no play make andybons a dull boy'],
-      },
-      {
-        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
-      },
-    ];
-    function diffTableHasContent() {
-      assertIsDefined(element.diffTable);
-      return element.diffTable.innerText.includes(content[0].a?.[0] ?? '');
-    }
-    await setupSampleDiff({content});
-    await waitUntil(diffTableHasContent);
-    element.diff = {...element.diff!};
-    await element.updateComplete;
-    // immediately cleaned up
-    assertIsDefined(element.diffTable);
-    assert.equal(element.diffTable.innerHTML, '');
-    element.renderDiffTable();
-    await element.updateComplete;
-    // rendered again
-    await waitUntil(diffTableHasContent);
-  });
-
   suite('selection test', () => {
     test('user-select set correctly on side-by-side view', async () => {
       const content = [
@@ -4044,8 +3885,9 @@
         },
       ];
       await setupSampleDiff({content});
-      await waitEventLoop();
 
+      // We are selecting "Non eram nescius..." on the left side.
+      // The default is `selected-right`, so we will have to click.
       const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
       mouseDown(diffLine);
@@ -4065,10 +3907,12 @@
           ],
         },
       ];
-      await setupSampleDiff({content});
       element.viewMode = DiffViewMode.UNIFIED;
-      await element.updateComplete;
-      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      await setupSampleDiff({content});
+
+      // We are selecting "all work and no play..." on the left side.
+      // The default is `selected-right`, so we will have to click.
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[0];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
       mouseDown(diffLine);
       assert.equal(getComputedStyle(diffLine).userSelect, 'text');
@@ -4140,16 +3984,6 @@
 
 suite('former gr-diff-builder tests', () => {
   let element: GrDiff;
-  let builder: GrDiffBuilder;
-  let diffTable: HTMLTableElement;
-
-  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
-    builder = new GrDiffBuilder(
-      createEmptyDiff(),
-      {...createDefaultDiffPrefs(), ...prefs},
-      diffTable
-    );
-  };
 
   const line = (text: string) => {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
@@ -4164,51 +3998,6 @@
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
     stubBaseUrl('/r');
-    setBuilderPrefs({});
-  });
-
-  [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();
-      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();
-      assert.equal(builder.prefs.line_length, 72);
-    });
-  });
-
-  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();
-    element.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', () => {
@@ -4227,7 +4016,7 @@
         <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
       `);
       str = el.textContent ?? '';
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      annotateElementSpy = sinon.spy(GrAnnotationImpl, 'annotateElement');
       layer = element.createIntralineLayer();
     });
 
@@ -4339,7 +4128,7 @@
 
       const str0 = slice(str, 0, 6);
       const str1 = slice(str, 6);
-      const numHighlightedChars = GrAnnotation.getStringLength(str1);
+      const numHighlightedChars = getStringLength(str1);
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4359,14 +4148,17 @@
     const lineNumberEl = document.createElement('td');
 
     setup(() => {
-      element.showTabs = true;
+      element.prefs = {...DEFAULT_PREFS, show_tabs: true};
       layer = element.createTabIndicatorLayer();
     });
 
     test('does nothing with empty line', () => {
       const l = line('');
       const el = document.createElement('div');
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4378,7 +4170,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4390,7 +4185,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4403,13 +4201,16 @@
     });
 
     test('does not annotate when disabled', () => {
-      element.showTabs = false;
+      element.prefs = {...DEFAULT_PREFS, show_tabs: false};
 
       const str = '\tlorem upsum';
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4421,7 +4222,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4445,7 +4249,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
@@ -4458,51 +4265,25 @@
     });
   });
 
-  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;
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        show_whitespace_errors: true,
+      };
       layer = element.createTrailingWhitespaceLayer();
     });
 
     test('does nothing with empty line', () => {
       const l = line('');
       const el = document.createElement('div');
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isFalse(annotateElementStub.called);
     });
@@ -4512,7 +4293,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isFalse(annotateElementStub.called);
     });
@@ -4522,7 +4306,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isTrue(annotateElementStub.called);
       assert.equal(annotateElementStub.lastCall.args[1], 11);
@@ -4534,7 +4321,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isTrue(annotateElementStub.called);
       assert.equal(annotateElementStub.lastCall.args[1], 11);
@@ -4546,7 +4336,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isTrue(annotateElementStub.called);
       assert.equal(annotateElementStub.lastCall.args[1], 11);
@@ -4558,7 +4351,10 @@
       const l = line(str);
       const el = document.createElement('div');
       el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isTrue(annotateElementStub.called);
       assert.equal(annotateElementStub.lastCall.args[1], 1);
@@ -4566,12 +4362,18 @@
     });
 
     test('does not annotate when disabled', () => {
-      element.showTrailingWhitespace = false;
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        show_whitespace_errors: 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');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
       assert.isFalse(annotateElementStub.called);
     });
@@ -4603,29 +4405,47 @@
 
     test('text', async () => {
       element.diff = {...createEmptyDiff(), content};
-      await waitForEventOnce(element.diffTable!, 'render-content');
-      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+      await waitUntil(() => element.groups.length > 2);
+      await element.updateComplete;
+      const bodies = [...(querySelectorAll(element.diffTable!, 'tbody') ?? [])];
+      assert.equal(bodies.length, 4);
+      assert.isTrue(bodies[0].innerHTML.includes('LOST'));
+      assert.isTrue(bodies[1].innerHTML.includes('FILE'));
+      assert.isTrue(bodies[2].innerHTML.includes('andybons a dull boy'));
+      assert.isTrue(bodies[3].innerHTML.includes('Non eram nescius'));
     });
 
     test('image', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
       element.isImageDiff = true;
-      await waitForEventOnce(element.diffTable!, 'render-content');
-      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+      await element.updateComplete;
+      const body = queryAndAssert(element, 'tbody.image-diff');
+      assert.lightDom.equal(
+        body,
+        /* HTML */ `
+          <label class="gr-diff">
+            <span class="gr-diff label"> No image </span>
+          </label>
+          <label class="gr-diff">
+            <span class="gr-diff label"> No image </span>
+          </label>
+        `
+      );
     });
 
     test('binary', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
-      await waitForEventOnce(element.diffTable!, 'render-content');
-      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 3);
+      await element.updateComplete;
+      const body = queryAndAssert(element, 'tbody.binary-diff');
+      assert.lightDom.equal(
+        body,
+        /* HTML */ '<span>Difference in binary files</span>'
+      );
     });
   });
 
   suite('context hiding and expanding', () => {
-    let dispatchStub: sinon.SinonStub;
-
     setup(async () => {
-      dispatchStub = sinon.stub(element.diffTable!, 'dispatchEvent');
       element.diff = {
         ...createEmptyDiff(),
         content: [
@@ -4635,15 +4455,12 @@
         ],
       };
       element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
       element.prefs = {
         ...DEFAULT_PREFS,
         context: 1,
       };
+      await waitUntil(() => element.groups.length > 2);
       await element.updateComplete;
-      element.legacyRender();
-      // Make sure all listeners are installed.
-      await element.untilGroupsRendered();
     });
 
     test('hides lines behind two context controls', () => {
@@ -4700,7 +4517,6 @@
     });
 
     test('unhideLine shows the line with context', async () => {
-      dispatchStub.reset();
       element.unhideLine(4, Side.LEFT);
 
       await waitUntil(() => {
@@ -4725,10 +4541,6 @@
       assert.include(diffRows[8].textContent, 'before');
       assert.include(diffRows[8].textContent, 'after');
       assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      await element.untilGroupsRendered();
-      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index e2837ab..24729ff 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -3,7 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {strToClassName} from '../../../utils/dom-util';
 import {Side} from '../../../constants/constants';
@@ -94,7 +94,7 @@
     }
 
     for (const range of ranges) {
-      GrAnnotation.annotateElement(
+      GrAnnotationImpl.annotateElement(
         el,
         range.start,
         range.end - range.start,
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index b90d6f7..5bfd94d 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -10,11 +10,11 @@
   CommentRangeLayer,
   GrRangedCommentLayer,
 } from './gr-ranged-comment-layer';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffLineType, Side} from '../../../api/diff';
 import {SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
 
 const rangeA: CommentRangeLayer = {
   side: Side.LEFT,
@@ -130,7 +130,7 @@
     }
 
     setup(() => {
-      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      annotateElementStub = sinon.stub(GrAnnotationImpl, 'annotateElement');
       el = document.createElement('div');
       el.setAttribute('data-side', Side.LEFT);
       line = new GrDiffLine(GrDiffLineType.BOTH);
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index 68aa3b4..756f6fa 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -40,20 +40,22 @@
     this.addEventListener('mousedown', e => this.handleMouseDown(e));
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        cursor: pointer;
-        font-family: var(--font-family);
-        position: absolute;
-        white-space: nowrap;
-      }
-      gr-tooltip[invisible] {
-        visibility: hidden;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          cursor: pointer;
+          font-family: var(--font-family);
+          position: absolute;
+          white-space: nowrap;
+        }
+        gr-tooltip[invisible] {
+          visibility: hidden;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index baa2ab4..4e166ba 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -3,7 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {annotateElement} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
@@ -212,7 +212,7 @@
     for (const range of ranges) {
       if (!CLASS_SAFELIST.has(range.className)) continue;
       if (range.length === 0) continue;
-      GrAnnotation.annotateElement(
+      annotateElement(
         el,
         range.start,
         range.length,
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 977f8a9..1524a78 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -12,11 +12,11 @@
 // exposed by shared gr-diff component.
 import '../api/embed';
 import '../scripts/bundled-polymer';
-import './diff/gr-diff/gr-diff';
-import './diff/gr-diff-cursor/gr-diff-cursor';
+import './diff-old/gr-diff/gr-diff';
+import './diff-old/gr-diff-cursor/gr-diff-cursor';
 import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
-import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrDiffCursor} from './diff-old/gr-diff-cursor/gr-diff-cursor';
+import {GrAnnotation} from './diff-old/gr-diff-highlight/gr-annotation';
 import {createDiffAppContext} from './gr-diff-app-context-init';
 import {injectAppContext} from '../services/app-context';
 
@@ -29,6 +29,3 @@
   GrDiffCursor,
   TokenHighlightLayer,
 };
-
-// TODO(oler): Remove when clients have adjusted to namespaced globals above
-window.GrAnnotation = GrAnnotation;
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 68ec76a..ba6e384 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
@@ -3084,37 +3084,48 @@
       });
   }
 
-  async setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
-    const lookupProject = await this._projectLookup[changeNum];
-    if (lookupProject && lookupProject !== project) {
-      console.warn(
-        'Change set with multiple project nums.' +
-          'One of them must be invalid.'
-      );
-    }
+  /**
+   * This can be called by the router, if the project can be determined from
+   * the URL. Or when handling a dashabord or a search response.
+   *
+   * Then we don't need to make a dedicated REST API call or have a fallback,
+   * if that fails.
+   */
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
     this._projectLookup[changeNum] = Promise.resolve(project);
   }
 
   getFromProjectLookup(
     changeNum: NumericChangeId
   ): Promise<RepoName | undefined> {
-    const project = this._projectLookup[`${changeNum}`];
-    if (project) {
-      return project;
-    }
+    // Hopefully setInProjectLookup() has already been called. Then we don't
+    // have to make a dedicated REST API call to look up the project.
+    let projectPromise = this._projectLookup[changeNum];
+    if (projectPromise) return projectPromise;
 
-    const onError = (response?: Response | null) => firePageError(response);
+    // Ignore errors, because we have some dedicated fallback logic, see below.
+    const onError = () => {};
+    projectPromise = this.getChange(changeNum, onError).then(change => {
+      if (change?.project) return change.project;
 
-    const projectPromise = this.getChange(changeNum, onError).then(change => {
-      if (!change || !change.project) {
-        return;
+      // In the very rare case that the change index cannot provide an answer
+      // (e.g. stale index) we should check, if the router has called
+      // setInProjectLookup() in the meantime. Then we can fall back to that.
+      const currentProjectPromise = this._projectLookup[changeNum];
+      if (currentProjectPromise !== projectPromise) {
+        return currentProjectPromise;
       }
-      this.setInProjectLookup(changeNum, change.project);
-      return change.project;
+
+      // No luck. Without knowing the project we cannot proceed at all.
+      firePageError(
+        new Response(
+          `Failed to lookup the repo for change number ${changeNum}`,
+          {status: 404}
+        )
+      );
+      return undefined;
     });
-
     this._projectLookup[changeNum] = projectPromise;
-
     return projectPromise;
   }
 
@@ -3304,10 +3315,6 @@
     return this.getDocsBaseUrlCachedPromise;
   }
 
-  testOnly_clearDocsBaseUrlCache() {
-    this.getDocsBaseUrlCachedPromise = undefined;
-  }
-
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
     filter = filter.trim();
     const encodedFilter = encodeURIComponent(filter);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index d570163..764aa98 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -38,6 +38,7 @@
 } from '../../constants/constants';
 import {
   BasePatchSetNum,
+  ChangeInfo,
   ChangeMessageId,
   CommentInfo,
   DashboardId,
@@ -62,6 +63,7 @@
 import {assert} from '@open-wc/testing';
 import {AuthService} from '../gr-auth/gr-auth';
 import {GrAuthMock} from '../gr-auth/gr-auth_mock';
+import {getBaseUrl} from '../../utils/url-util';
 
 const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
   ListChangesOption.CHANGE_ACTIONS,
@@ -356,7 +358,7 @@
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
-        'test52/accounts/?o=DETAILS&q=%22bro%22'
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22`
       );
     });
 
@@ -365,7 +367,7 @@
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
-        'test53/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682'
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682`
       );
     });
 
@@ -379,8 +381,7 @@
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
-        'test54/accounts/?o=DETAILS&q=%22bro%22%20and%20' +
-          'cansee%3A341682%20and%20is%3Aactive'
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682%20and%20is%3Aactive`
       );
     });
   });
@@ -1154,32 +1155,46 @@
   });
 
   test('setInProjectLookup', async () => {
-    await element.setInProjectLookup(
-      555 as NumericChangeId,
-      'project' as RepoName
-    );
+    element.setInProjectLookup(555 as NumericChangeId, 'project' as RepoName);
     const project = await element.getFromProjectLookup(555 as NumericChangeId);
     assert.deepEqual(project, 'project' as RepoName);
   });
 
   suite('getFromProjectLookup', () => {
-    test('getChange succeeds, no project', async () => {
-      sinon.stub(element, 'getChange').resolves(null);
-      const val = await element.getFromProjectLookup(555 as NumericChangeId);
-      assert.strictEqual(val, undefined);
+    const changeNum = 555 as NumericChangeId;
+    const repo = 'test-repo' as RepoName;
+
+    test('getChange fails to yield a project', async () => {
+      const promise = mockPromise<null>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      promise.resolve(null);
+
+      assert.isUndefined(await projectLookup);
     });
 
     test('getChange succeeds with project', async () => {
-      sinon
-        .stub(element, 'getChange')
-        .resolves({...createChange(), project: 'project' as RepoName});
-      const projectLookup = element.getFromProjectLookup(
-        555 as NumericChangeId
-      );
-      const val = await projectLookup;
-      assert.equal(val, 'project' as RepoName);
+      const promise = mockPromise<null | ChangeInfo>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      promise.resolve({...createChange(), project: repo});
+
+      assert.equal(await projectLookup, repo);
       assert.deepEqual(element._projectLookup, {'555': projectLookup});
     });
+
+    test('getChange fails, but a setInProjectLookup() call is used as fallback', async () => {
+      const promise = mockPromise<null>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      element.setInProjectLookup(changeNum, repo);
+      promise.resolve(null);
+
+      assert.equal(await projectLookup, repo);
+    });
   });
 
   suite('getChanges populates _projectLookup', () => {
@@ -1592,10 +1607,11 @@
     test('null config', async () => {
       const probePathMock = sinon.stub(element, 'probePath').resolves(true);
       const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.isTrue(
-        probePathMock.calledWith('test91/Documentation/index.html')
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
       );
-      assert.equal(docsBaseUrl, 'test91/Documentation');
+      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
     });
 
     test('no doc config', async () => {
@@ -1605,10 +1621,11 @@
         gerrit: createGerritInfo(),
       };
       const docsBaseUrl = await element.getDocsBaseUrl(config);
-      assert.isTrue(
-        probePathMock.calledWith('test92/Documentation/index.html')
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
       );
-      assert.equal(docsBaseUrl, 'test92/Documentation');
+      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
     });
 
     test('has doc config', async () => {
@@ -1625,8 +1642,9 @@
     test('no probe', async () => {
       const probePathMock = sinon.stub(element, 'probePath').resolves(false);
       const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.isTrue(
-        probePathMock.calledWith('test94/Documentation/index.html')
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
       );
       assert.isNotOk(docsBaseUrl);
     });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 24ba0e5..f1fae3b 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -375,7 +375,6 @@
   ): Promise<string>;
 
   getDocsBaseUrl(config?: ServerInfo): Promise<string | null>;
-  testOnly_clearDocsBaseUrlCache(): void;
 
   createChange(
     repo: RepoName,
diff --git a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 842dace..34187fe 100644
--- a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -3,25 +3,24 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  getAccountDisplayName,
-  getGroupDisplayName,
-} from '../../utils/display-name-util';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
 import {
-  AccountInfo,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
   NumericChangeId,
   ServerInfo,
+  SuggestedReviewerAccountInfo,
   SuggestedReviewerInfo,
   Suggestion,
 } from '../../types/common';
-import {assertNever} from '../../utils/common-util';
 import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
 import {allSettled, isFulfilled} from '../../utils/async-util';
 import {isDefined, ParsedChangeInfo} from '../../types/types';
-import {accountKey} from '../../utils/account-util';
+import {
+  accountKey,
+  getSuggestedReviewerName,
+  isAccountSuggestion,
+} from '../../utils/account-util';
 import {
   AccountId,
   ChangeInfo,
@@ -96,30 +95,11 @@
   makeSuggestionItem(
     suggestion: Suggestion
   ): AutocompleteSuggestion<SuggestedReviewerInfo> {
-    if (isReviewerAccountSuggestion(suggestion)) {
-      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-      return {
-        name: getAccountDisplayName(this.config, suggestion.account),
-        value: suggestion,
-      };
-    }
-
-    if (isReviewerGroupSuggestion(suggestion)) {
-      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
-      return {
-        name: getGroupDisplayName(suggestion.group),
-        value: suggestion,
-      };
-    }
-
-    if (isAccountSuggestion(suggestion)) {
-      // Reviewer is an account suggestion from getSuggestedAccounts.
-      return {
-        name: getAccountDisplayName(this.config, suggestion),
-        value: {account: suggestion, count: 1},
-      };
-    }
-    assertNever(suggestion, 'Received an incorrect suggestion');
+    const name = getSuggestedReviewerName(suggestion, this.config);
+    const value = isAccountSuggestion(suggestion)
+      ? ({account: suggestion, count: 1} as SuggestedReviewerAccountInfo)
+      : suggestion;
+    return {name, value};
   }
 
   private getSuggestionsForChange(
@@ -160,7 +140,3 @@
   }
   return undefined;
 }
-
-function isAccountSuggestion(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index f8e4160..bfa881e 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -321,9 +321,6 @@
     }
     return Promise.resolve('');
   },
-  testOnly_clearDocsBaseUrlCache() {
-    return;
-  },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 026e3b5..73ee6aa 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -18,6 +18,10 @@
 import {DependencyToken} from '../models/dependency';
 import {storageServiceToken} from '../services/storage/gr-storage_impl';
 import {highlightServiceToken} from '../services/highlight/highlight-service';
+import {
+  diffModelToken,
+  DiffModel,
+} from '../embed/diff/gr-diff-model/gr-diff-model';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
@@ -49,5 +53,6 @@
     highlightServiceToken,
     () => new MockHighlightService(appContext.reportingService)
   );
+  dependencies.set(diffModelToken, () => new DiffModel());
   return dependencies;
 }
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 554fa23..4a709b0 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,10 +36,6 @@
     };
 
     /** Enhancements on Gr elements or utils */
-    // TODO(TS): should clean up those and removing them may break certain plugin behaviors
-    // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
-    // use any for them for now
-    GrAnnotation: unknown;
     // Heads up! There is a known plugin dependency on GrPluginActionContext.
     GrPluginActionContext: unknown;
   }
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 7dc9a62..68f6b42 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -16,10 +16,17 @@
   ReviewerInput,
   ServerInfo,
   UserId,
+  Suggestion,
+  isReviewerAccountSuggestion,
+  isReviewerGroupSuggestion,
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
 import {assertNever, hasOwnProperty} from './common-util';
-import {getDisplayName} from './display-name-util';
+import {
+  getAccountDisplayName,
+  getDisplayName,
+  getGroupDisplayName,
+} from './display-name-util';
 import {getApprovalInfo} from './label-util';
 import {ParsedChangeInfo} from '../types/types';
 
@@ -220,3 +227,29 @@
   }
   throw new Error('Must be either an account or a group.');
 }
+
+export function isAccountSuggestion(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
+}
+
+export function getSuggestedReviewerName(
+  suggestion: Suggestion,
+  config?: ServerInfo
+) {
+  if (isAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getSuggestedAccounts.
+    return getAccountDisplayName(config, suggestion);
+  }
+
+  if (isReviewerAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+    return getAccountDisplayName(config, suggestion.account);
+  }
+
+  if (isReviewerGroupSuggestion(suggestion)) {
+    // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+    return getGroupDisplayName(suggestion.group);
+  }
+
+  assertNever(suggestion, 'Received an incorrect suggestion');
+}
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index e8bd3d8..f3bea34 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -8,7 +8,7 @@
   "devDependencies": {
     "@open-wc/karma-esm": "^3.0.9",
     "@open-wc/semantic-dom-diff": "^0.19.5",
-    "@open-wc/testing": "^3.1.6",
+    "@open-wc/testing": "^3.1.8",
     "@web/dev-server-esbuild": "^0.3.2",
     "@web/test-runner": "^0.14.0",
     "@web/test-runner-playwright": "^0.9.0",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 35409a8..c1a2bfb 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -1124,26 +1124,26 @@
     "@types/chai" "^4.3.1"
     "@web/test-runner-commands" "^0.6.1"
 
-"@open-wc/testing-helpers@^2.1.2":
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
-  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+"@open-wc/testing-helpers@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.2.1.tgz#10ed75c33faec0ed68c76e027ebe8be262a36921"
+  integrity sha512-8zuJK7tUQYuXRIC/cVcPbAPOhtBJCe3Jfpk7im7WK0DIAXH9Q/ycB+yu3R8g4BQ31f/FdLjIFRbPZzIU75kkRg==
   dependencies:
     "@open-wc/scoped-elements" "^2.1.3"
     lit "^2.0.0"
     lit-html "^2.0.0"
 
-"@open-wc/testing@^3.1.6":
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
-  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+"@open-wc/testing@^3.1.8":
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.8.tgz#3760a354e421a38bf432010a067ff3d3fdb60b1e"
+  integrity sha512-SpKhlSwCqUkVOOmdb9RanOQgqv4T32wzExkvuaVcUFcBeUdpwQsg1+WYpdv31Z4cRCkAhQ4A8OIpGphzqF8T7w==
   dependencies:
     "@esm-bundle/chai" "^4.3.4-fix.0"
     "@open-wc/chai-dom-equals" "^0.12.36"
     "@open-wc/semantic-dom-diff" "^0.19.7"
-    "@open-wc/testing-helpers" "^2.1.2"
+    "@open-wc/testing-helpers" "^2.2.1"
     "@types/chai" "^4.2.11"
-    "@types/chai-dom" "^0.0.12"
+    "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
     chai-a11y-axe "^1.3.2"
 
@@ -1289,10 +1289,10 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
   integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
 
-"@types/chai-dom@^0.0.12":
-  version "0.0.12"
-  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
-  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+"@types/chai-dom@^1.11.0":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.0.tgz#e9bd01f3408b2ffd27755fe4418ff92ffd8f4e66"
+  integrity sha512-Aja99Mmnny+Sz+T2hBK3oEsrcy18yabplT0pGX/QwIke9jMJHdvHlV2f4Tmq5SqxTMYwt1Zjbisv/4r83EUIHw==
   dependencies:
     "@types/chai" "*"
 
diff --git a/yarn.lock b/yarn.lock
index d66270b..df4e166 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -51,53 +51,52 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@bazel/concatjs@^5.5.0":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
-  integrity sha512-hwG+ahivR20Z3iTOlkUz3OdwnW/PUaZyyz8BIX+GNUTg6U3rPHK51CavUirMupOU/LRJ5GyCwBNAAtjCyquo2g==
+"@bazel/concatjs@^5.8.0":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.8.1.tgz#dd20882429e382cae79c08cbd3238dfc680d2d67"
+  integrity sha512-TkARsNUxgi3bjFeGwIGlffmQglNhuR9qK9uE7uKhdBZvQE5caAWVCjYiMTzo3viKDhwKn5QNRcHY5huuJMVFfA==
   dependencies:
     protobufjs "6.8.8"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/rollup@^5.5.0":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.5.0.tgz#1e152d6147ef5583ec9fd872756c9d0635db73c7"
-  integrity sha512-8SRbgVfaYdNb6PyIypj8jzzJHhlIRyMH3s5KpXODsjD+mXECH4jQxJ8VcRkt0f0exsgB12gK5dmoUK/F2PDKCw==
+"@bazel/rollup@^5.8.0":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.8.1.tgz#ee876c35595b456f700d258385412e4f0dd57c15"
+  integrity sha512-Ys+UWbRp1TY2j+z15N+SZgID/nuqAtJTgJDsz0NZVjm8F8KzmgXxLDnBb/cUKFVk83pNOAi84G/bq1tINjMSNA==
   dependencies:
-    "@bazel/worker" "5.5.0"
+    "@bazel/worker" "5.8.1"
 
-"@bazel/terser@^5.5.0":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.5.0.tgz#3b2b582a417d99d59ae99b50d74576ca0719c03a"
-  integrity sha512-aBjNmJ7TbcD7cKAdFErYQYXn4OqTvrmqrtN6Z6Wnv82d+23kbEsF427ixgdCO3GTQJDw7+x7K9TP2CGogaGtcg==
+"@bazel/terser@^5.8.0":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.8.1.tgz#729a0ec6dcc83e99c4f6d3f2bebb0ff254c10c48"
+  integrity sha512-TPjSDhw1pSZt9P2hd/22IJwl8KCZiJL+u2gB5mghBTCFDVdC5Dgsx135pFtvlqc6LjjOvd3s6dzcQr0YJo2HSg==
 
-"@bazel/typescript@^5.5.0":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.5.0.tgz#053c255acb1b3cac23d24984cd8d5d5542fe1f7c"
-  integrity sha512-Ord0+nCj+B1M4NDbe0uqZf2FyOCzaDAlc4DAsr5UKJrArCipIbMTEAxlsEk+WAYBNAFGO/FS9/zlDtLceqpHqw==
+"@bazel/typescript@^5.8.0":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.8.1.tgz#74a76af434fad7930893cf3e98b4cc201e52dc65"
+  integrity sha512-NAJ8WQHZL1WE1YmRoCrq/1hhG15Mvy/viWh6TkvFnBeEhNUiQUsA5GYyhU1ztnBIYW03nATO3vwhAEfO7Q0U5g==
   dependencies:
-    "@bazel/worker" "5.5.0"
-    protobufjs "6.8.8"
+    "@bazel/worker" "5.8.1"
     semver "5.6.0"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/worker@5.5.0":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.5.0.tgz#d30b75e46f2052d33bcda251b328d36655a5636f"
-  integrity sha512-pYfjJKg4D1CQ/AJ1UGC5ySyH09gDqNiBrQJ0uMYVewIBW24uOAkKsJfTE2y4ES0UL1Ik758WO0la0mJeFOKScg==
+"@bazel/worker@5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.8.1.tgz#65af7a70dd2f1aaedd6c19330abd9a198f96e7b2"
+  integrity sha512-GMyZSNW3F34f9GjbJqvs1aHyed5BNrNeiDzNJhC1fIizo/UeBM21oBBONIYLBDoBtq936U85VyPZ76JaP/83hw==
   dependencies:
     google-protobuf "^3.6.1"
 
-"@es-joy/jsdoccomment@~0.36.1":
-  version "0.36.1"
-  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz#c37db40da36e4b848da5fd427a74bae3b004a30f"
-  integrity sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==
+"@es-joy/jsdoccomment@~0.39.3":
+  version "0.39.4"
+  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.39.4.tgz#6b8a62e9b3077027837728818d3c4389a898b392"
+  integrity sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==
   dependencies:
     comment-parser "1.3.1"
-    esquery "^1.4.0"
-    jsdoc-type-pratt-parser "~3.1.0"
+    esquery "^1.5.0"
+    jsdoc-type-pratt-parser "~4.0.0"
 
 "@esbuild/linux-loong64@0.14.54":
   version "0.14.54"
@@ -690,6 +689,11 @@
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
+are-docs-informative@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963"
+  integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==
+
 argparse@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
@@ -1704,17 +1708,18 @@
     resolve "^1.22.0"
     tsconfig-paths "^3.14.1"
 
-eslint-plugin-jsdoc@^39.6.4:
-  version "39.6.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.4.tgz#b940aebd3eea26884a0d341785d2dc3aba6a38a7"
-  integrity sha512-fskvdLCfwmPjHb6e+xNGDtGgbF8X7cDwMtVLAP2WwSf9Htrx68OAx31BESBM1FAwsN2HTQyYQq7m4aW4Q4Nlag==
+eslint-plugin-jsdoc@^44.2.4:
+  version "44.2.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-44.2.4.tgz#0bdc163771504ec7330414eda6a7dbae67156ddb"
+  integrity sha512-/EMMxCyRh1SywhCb66gAqoGX4Yv6Xzc4bsSkF1AiY2o2+bQmGMQ05QZ5+JjHbdFTPDZY9pfn+DsSNP0a5yQpIg==
   dependencies:
-    "@es-joy/jsdoccomment" "~0.36.1"
+    "@es-joy/jsdoccomment" "~0.39.3"
+    are-docs-informative "^0.0.2"
     comment-parser "1.3.1"
     debug "^4.3.4"
     escape-string-regexp "^4.0.0"
-    esquery "^1.4.0"
-    semver "^7.3.8"
+    esquery "^1.5.0"
+    semver "^7.5.1"
     spdx-expression-parse "^3.0.1"
 
 eslint-plugin-lit@^1.6.1:
@@ -1859,6 +1864,13 @@
   dependencies:
     estraverse "^5.1.0"
 
+esquery@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b"
+  integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==
+  dependencies:
+    estraverse "^5.1.0"
+
 esrecurse@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
@@ -2918,10 +2930,10 @@
   dependencies:
     argparse "^2.0.1"
 
-jsdoc-type-pratt-parser@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e"
-  integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw==
+jsdoc-type-pratt-parser@~4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz#136f0571a99c184d84ec84662c45c29ceff71114"
+  integrity sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==
 
 json-buffer@3.0.0:
   version "3.0.0"
@@ -4107,10 +4119,10 @@
   dependencies:
     lru-cache "^6.0.0"
 
-semver@^7.3.8:
-  version "7.3.8"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
-  integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+semver@^7.5.1:
+  version "7.5.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec"
+  integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==
   dependencies:
     lru-cache "^6.0.0"