Merge "Add dark theme colors for bulk vote dialog"
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 0575eb9..358324d 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -136,13 +136,13 @@
 	project.  Disabled by default.
 
 --use-signed-off-by::
---so:
+--so::
 	If enabled, each change must contain a Signed-off-by line
 	from either the author or the uploader in the commit message.
 	Disabled by default.
 
 --create-new-change-for-all-not-in-target::
---ncfa:
+--ncfa::
 	If enabled, a new change is created for every commit that is not in
 	the target branch. If the pushed commit is a merge commit, this flag is
 	ignored for that push. To avoid accidental creation of a large number
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index f0ad460..5fd0bfc 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -324,6 +324,19 @@
 
 comment:: Review comment cover message.
 
+=== Project Head Updated
+
+Sent when project's head is updated.
+
+type:: "project-head-updated"
+
+oldHead:: The old project head name
+
+newHead:: The new project head name
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 0444fab..6e76a8a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -34,6 +34,11 @@
 |Owner
 |The contributor who created the change.
 
+|Uploader
+|The user that uploaded the current patch set (e.g. the user that executed the
+`git push` command, or the user that triggered the patch set creation through
+an action in the UI).
+
 |Assignee
 |The contributor responsible for the change. Often used when a change has
 mulitple reviewers to identify the individual responsible for final approval.
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index fcc8b7e..107473a 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -24,33 +24,31 @@
 link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
 review them adhoc.
 
-For large/complex features, it is required to follow the
-link:#design-driven-contribution-process[design-driven contribution
-process] and specify the feature in a link:dev-design-docs.html[design
-doc,role=external,window=_blank] before starting with the implementation.
+For large/complex features, it is required to specify the feature in a
+link:dev-design-docs.html[design document,role=external,window=_blank] before
+starting implementation, as per the
+link:#design-driven-contribution-process[design-driven contribution process].
 
 If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
-choose the lightweight contribution process and during the review it turns out
-that the feature is too large or complex,
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
-require to follow the design-driven contribution process instead.
+choose the lightweight contribution process but the feature is found to be 
+large or complex, link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+can require that the design-driven contribution process be followed instead.
 
 If you are in doubt which process is right for you, consult the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
-the Gerrit project, including link:dev-roles.html#maintainer[
-maintainers,role=external,window=_blank]. When reading this document, keep in
-mind that maintainers are also contributors when they contribute code.
+the Gerrit project. link:dev-roles.html#maintainer[
+Maintainers,role=external,window=_blank] are also considered contributors
+when they contribute code.
 
 If a new feature is large or complex, it is often difficult to find a
-maintainer who can take the time that is needed for a thorough review,
-and who can help with getting the changes submitted. To avoid that this
-results in unpredictable long waiting times during code review,
-contributors can ask for link:#mentorship[mentor support]. A mentor
-helps with timely code reviews and technical guidance. Doing the
-implementation is still the responsibility of the contributor.
+maintainer who can take the time that is needed for a thorough review. This
+can result in unpredictably long waiting times before the changes are
+submitted. To avoid that, contributors can ask for link:#mentorship[mentor support].
+A mentor helps with timely code reviews and technical guidance, though the 
+implementation itself is still the responsibility of the contributor.
 
 [[comparison]]
 === Quick Comparison
@@ -66,8 +64,8 @@
 |Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
 a link:#mentorship[mentor] was assigned)
 |Caveats |features may get vetoed after the implementation was already
-done, maintainers may make the design-driven contribution process
-required if a change gets too complex/large|design doc must stay open
+done, maintainers may require the design-driven contribution process
+be followed if a change gets too complex/large|design doc must stay open
 for a minimum of 10 calendar days, a mentor may not be available
 immediately
 |Applicable to|documentation updates, bug fixes, small features|
@@ -83,40 +81,32 @@
 link:#design-driven-contribution-process[design-driven contribution
 process] is required.
 
-As Gerrit is a code review tool, naturally contributions will
-be reviewed before they will get submitted to the code base.  To
-start your contribution, please make a git commit and upload it
-for review to the link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
-help speed up the review of your change, review these link:dev-crafting-changes.html[
+To start contributing to Gerrit, upload your git commit for review to the
+link:https://gerrit-review.googlesource.com/[gerrit-review.googlesource.com,
+role=external,window=_blank] Gerrit server. Review these link:dev-crafting-changes.html[
 guidelines,role=external,window=_blank] before submitting your change.  You can
-view the pending Gerrit contributions and their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
+view pending contributions link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
-your change to get reviewed.  Naturally there are fewer
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
-can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
-so anything that you can do to ensure that your contribution will undergo fewer
-revisions will speed up the contribution process.  This includes
-helping out reviewing other people's changes to relieve the load from
-the maintainers.  Even if you are not familiar with Gerrit's internals,
+your change to get reviewed. Anything that you can do to ensure that your
+contribution will undergo fewer revisions will speed up the contribution process.
+This includes helping out reviewing other people's changes to relieve the
+load from the maintainers. Even if you are not familiar with Gerrit's internals,
 it would be of great help if you can download, try out, and comment on
-new features.  If it works as advertised, say so, and if you have the
+new features. If it works as advertised, say so, and if you have the
 privileges to do so, go ahead and give it a `+1 Verified`.  If you
 would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your reviewers,
-the quicker your change might get merged!  Try to reply to every
-comment after submitting your new patch, particularly if you decided
-against making the suggested change. Reviewers don't want to seem like
-nags and pester you if you haven't replied or made a fix, so it helps
-them know if you missed it or decided against it.
+Finally, the quicker you respond to the comments of your reviewers, the
+quicker your change can be merged! Try to reply to every comment after
+submitting your new patch, particularly if you decided against making the
+suggested change. Reviewers don't want to seem like nags and pester you
+if you haven't replied or made a fix, so it helps them know if you missed
+it or decided against it.
 
-Features or API extensions, even if they are small, will incur
-long-time maintenance and support burden, so they should be left
-pending for at least 24 hours to give maintainers in all timezones a
-chance to evaluate.
+A new feature or API extension, even if small, can incur a long-time
+maintenance and support burden and should be left pending for 24 hours
+to give maintainers in all time zones a chance to evaluate the change.
 
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
@@ -126,19 +116,19 @@
 
 For large/complex features it is important to:
 
-* agree on the functionality and scope before spending too much time
-  on the implementation
+* agree on functionality and scope before spending too much time on
+  implementation
 * ensure that they are in line with Gerrit's project scope and vision
 * ensure that they are well aligned with other features
-* think about possibilities how the feature could be evolved over time
+* consider how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
 feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
 and get it approved by the
-link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
 before starting the implementation.
 
-The design-driven contribution process has the following steps:
+The design-driven contribution process consists of the following steps:
 
 * A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
   link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
@@ -155,40 +145,31 @@
   be accepted.
 * To be submitted, the design doc needs to be approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
-* After the design was approved, the implementation is done by pushing
-  changes for review, see link:#lightweight-contribution-process[
+* After the design is approved, it is implemented by pushing
+  changes for review, see the link:#lightweight-contribution-process[
   lightweight contribution process]. Changes that are associated with
   a design should all share a common hashtag. The contributor is the
-  main driver of the implementation and responsible that it is done.
-  Others from the Gerrit community are usually much welcome to help
-  with the implementation.
+  main driver of the implementation and responsible for its completion.
+  Others from the Gerrit community are usually welcome to help.
 
-In order to be accepted/submitted, it is not necessary that the design
-doc fully specifies all the details, but the idea of the feature and
-how it fits into Gerrit should be sufficiently clear (judged by the
-steering committee). Contributors are expected to keep the design doc
-updated and fill in gaps while they go forward with the implementation.
-We expect that implementing the feature and updating the design doc
-will be an iterative process.
+The design doc does not need to fully specify each detail of the feature,
+but its concept and how it fits into Gerrit should be sufficiently clear,
+as judged by the steering committee. Contributors are expected to keep
+the design doc updated and fill in gaps while they go forward with the
+implementation. We expect that implementing the feature and updating the
+design doc will be an iterative process.
 
-While the design doc is still in review, contributors may already start
-with the implementation (e.g. do some prototyping to demonstrate parts
-of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet. Another way to demonstrate the
-design can be to add screenshots or the like, early enough in the doc.
+While the design doc is still in review, contributors may start with the
+implementation (e.g. do some prototyping to demonstrate parts of the
+proposed design), but those changes should not be submitted while the
+design is not yet approved. Another way to demonstrate the design can be
+mocking screenshots in the doc.
 
 By approving a design, the steering committee commits to:
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
-
-If the implementation of a feature gets stuck and it's unclear whether
-the feature gets fully done, it should be discussed with the steering
-committee how to proceed. If the contributor cannot commit to finish
-the implementation and no other contributor can take over, changes that
-have already been submitted for the feature might get reverted so that
-there is no unused or half-finished code in the code base.
+  mentor,role=external,window=_blank] if requested (see link:#mentorship[mentorship]).
 
 For contributors, the design-driven contribution process has the
 following advantages:
@@ -196,12 +177,11 @@
 * By writing a design doc, the feature gets more attention. During the
   design review, feedback from various sides can be collected, which
   likely leads to improvements of the feature.
-* Once a design was approved by the
+* Once a design is approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   the contributor can be almost certain that the feature will be accepted.
-  Hence, there is only a low risk to invest into implementing a feature
-  and see it being rejected later during the code review, as it can
-  happen with the lightweight contribution process.
+  Hence, there little risk of the feature being rejected later in code review,
+  as can occur with the lightweight contribution process.
 * The contributor can link:#mentorship[get a dedicated mentor assigned]
   who provides timely reviews and serves as a contact person for
   technical questions and discussing details of the design.
@@ -249,12 +229,11 @@
 * done criteria that define when the feature is done and the mentorship
   ends
 
-If a feature is not finished in time, it should be discussed with the
-steering committee how to proceed. If the contributor cannot commit to
-finish the implementation in time and no other contributor can take
-over, changes that have already been submitted for the feature might
-get reverted so that there is no unused or half-finished code in the
-code base.
+If a feature implementation is not completed in time and no contributors
+can commit to finishing the implementation, changes that have already been
+submitted for the feature may be reverted to avoid unused or half-finished
+code in the code base. In these circumstances, the steering committee
+determines how to proceed.
 
 [[esc-dd-evaluation]]
 == How the ESC evaluates design documents
@@ -314,7 +293,7 @@
 === Core vs. Plugin decision
 Q: `Would this fit better in a plugin?`
 
-* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+* Yes: The proposed feature or rework is an implementation (e.g. Lucene
   is an index implementation) of a generic concept that others
   might want to implement differently.
 * Yes: The proposed feature or rework is very specific to a custom setup.
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 9df4b04..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -456,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ae0c0a6..64845da 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2658,7 +2658,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        ||
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2729,7 +2729,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        |optional|
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 6fa584ac..a61d86d 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3393,6 +3393,133 @@
   HTTP/1.1 200 OK
 ----
 
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
 
 [[ids]]
 == IDs
@@ -3421,6 +3548,11 @@
 === \{label-name\}
 The name of a review label.
 
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3970,7 +4102,7 @@
 |`value`            ||
 The effective boolean value.
 |`configured_value` ||
-The configured value, can be `TRUE`, `FALSE` or `INHERITED`.
+The configured value, can be `TRUE`, `FALSE` or `INHERIT`.
 |`inherited_value`  |optional|
 The boolean value inherited from the parent. +
 Not set if there is no parent.
@@ -4366,6 +4498,57 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
 [[submit-type-info]]
 === SubmitTypeInfo
 Information about the link:config-project-config.html#submit-type[default submit
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index f716cb0..7816f5f 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -363,7 +363,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted. For example, to match all XML
+can be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -371,8 +371,8 @@
 +
 *More examples:*
 
-* `-file:^path/.*` - changes that do not modify files from `path/`.
-* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+* `-path:^path/.*` - changes that do not modify files from `path/`.
+* `path:{^~(path/.*)}` - changes that modify files not from `path/` (but may
 contain files from `path/`).
 
 [[file]]
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 0acf3bc..3d90bf0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
@@ -103,6 +104,7 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<AttentionSetListener> attentionSetListeners;
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -147,7 +149,8 @@
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
-      DynamicMap<ChangeIsOperandFactory> isOperands) {
+      DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<AttentionSetListener> attentionSetListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -187,6 +190,7 @@
     this.reviewerDeletedListeners = reviewerDeletedListeners;
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
+    this.attentionSetListeners = attentionSetListeners;
   }
 
   public Registration newRegistration() {
@@ -330,6 +334,10 @@
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    public Registration add(AttentionSetListener attentionSetListener) {
+      return add(attentionSetListeners, attentionSetListener);
+    }
+
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index b91a56a..c1029be 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Objects;
@@ -140,8 +139,7 @@
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
       Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
@@ -496,13 +494,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-    // Instants
-    @SuppressWarnings("JdkObsolete")
     private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen().toInstant())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -512,7 +507,7 @@
          * here and simply add a second. */
         now = now.plusSeconds(1);
       }
-      return new PersonIdent(oldPatchsetCommitter, Timestamp.from(now));
+      return new PersonIdent(oldPatchsetCommitter, now);
     }
 
     private long asSeconds(Instant date) {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 59475a4..587c2c7 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -227,6 +227,8 @@
 
   LabelApi label(String labelName) throws RestApiException;
 
+  SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
   /**
    * Adds, updates and deletes label definitions in a batch.
    *
@@ -426,6 +428,11 @@
     }
 
     @Override
+    public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
new file mode 100644
index 0000000..a6e79db
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -0,0 +1,60 @@
+// 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.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface SubmitRequirementApi {
+  /** Create a new submit requirement. */
+  SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
+
+  /** Get existing submit requirement. */
+  SubmitRequirementInfo get() throws RestApiException;
+
+  /** Update existing submit requirement. */
+  SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
+
+  /** Delete existing submit requirement. */
+  void delete() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements SubmitRequirementApi {
+    @Override
+    public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 6acf3f4..7ece6f6 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -102,6 +102,7 @@
   }
 
   public enum Theme {
+    AUTO,
     DARK,
     LIGHT
   }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..9347e7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -0,0 +1,45 @@
+// 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.extensions.common;
+
+public class SubmitRequirementInfo {
+  /** Name of the submit requirement. */
+  public String name;
+
+  /** Description of the submit requirement. */
+  public String description;
+
+  /**
+   * Expression string to be evaluated on a change. Decides if this submit requirement is applicable
+   * on the given change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change regardless of the evaluation of the {@link
+   * #submittabilityExpression}.
+   */
+  public String overrideExpression;
+
+  /** Boolean indicating if this submit requirement can be overridden in child projects. */
+  public boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index cb9d855..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -72,14 +72,14 @@
 
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(gitPerson.date.getTime()).isEqualTo(ident.getWhen().getTime());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/AttentionSetListener.java b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
new file mode 100644
index 0000000..ada30ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
@@ -0,0 +1,46 @@
+// 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.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Set;
+
+/** Notified whenever the attention set is changed. */
+@ExtensionPoint
+public interface AttentionSetListener {
+  interface Event extends ChangeEvent {
+
+    /**
+     * Returns the users added to the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersAdded();
+
+    /**
+     * Returns the users removed from the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersRemoved();
+  }
+
+  /**
+   * This function will be called when the attention set changes
+   *
+   * @param event The event that changed the attention set
+   */
+  void onAttentionSetChanged(Event event);
+}
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index f38653d..543e794 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -361,7 +361,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       String requestUri = requestUri(req);
 
-      try (PerThreadCache ignored = PerThreadCache.create(req)) {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         List<IdString> path = splitPath(req);
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..52955d3 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -38,7 +38,10 @@
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
 
   // Lucene index was changed to add an additional field for sorting.
-  static final Schema<ProjectData> V4 = schema(V3);
+  @Deprecated static final Schema<ProjectData> V4 = schema(V3);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ProjectData> V5 = schema(V4);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/json/OptionalTypeAdapter.java b/java/com/google/gerrit/json/OptionalTypeAdapter.java
new file mode 100644
index 0000000..9bfa72d
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalTypeAdapter.java
@@ -0,0 +1,69 @@
+// 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.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public class OptionalTypeAdapter
+    implements JsonSerializer<Optional<?>>, JsonDeserializer<Optional<?>> {
+
+  private static final String VALUE = "value";
+
+  @Override
+  public JsonElement serialize(Optional<?> src, Type typeOfSrc, JsonSerializationContext context) {
+    Optional<?> optional = src == null ? Optional.empty() : src;
+    JsonObject json = new JsonObject();
+    json.add(VALUE, optional.map(context::serialize).orElse(JsonNull.INSTANCE));
+    return json;
+  }
+
+  @Override
+  public Optional<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.getAsJsonObject().has(VALUE)) {
+      return Optional.empty();
+    }
+
+    JsonElement value = json.getAsJsonObject().get(VALUE);
+    if (value == null || value.isJsonNull()) {
+      return Optional.empty();
+    }
+
+    // handle the situation when one uses Optional without type parameter which is an equivalent of
+    // <?> type
+    ParameterizedType parameterizedType =
+        (ParameterizedType) new TypeLiteral<Optional<?>>() {}.getType();
+    if (typeOfT instanceof ParameterizedType) {
+      parameterizedType = (ParameterizedType) typeOfT;
+      if (parameterizedType.getActualTypeArguments().length != 1) {
+        throw new JsonParseException("Expected one parameter type in Optional.");
+      }
+    }
+
+    Type optionalOf = parameterizedType.getActualTypeArguments()[0];
+    return Optional.of(context.deserialize(value, optionalOf));
+  }
+}
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 934b27f..fe3fc15 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -72,7 +72,7 @@
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
     FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index bd34743..14ad528 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -49,7 +49,7 @@
 
 public class QueryBuilder<V> {
   /** @param name field name qparam i key value */
-  static Term intTerm(String name, int i) {
+  static Term intTerm(String name) {
     checkState(false, "Lucene index implementation removed legacy numeric type");
     return null;
   }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index c56a8d9..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -30,7 +30,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.Date;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@@ -61,16 +60,12 @@
     this.allUsers = allUsers.get();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public Account insert(Account.Builder account) throws IOException {
     File path = getPath();
     try (Repository repo = new FileRepository(path);
         ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(
-              new GerritPersonIdentProvider(flags.cfg).get(), Date.from(account.registeredOn()));
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 020705e..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -166,14 +165,10 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
-        new PersonIdent(
-            new GerritPersonIdentProvider(flags.cfg).get(), Timestamp.from(groupCreatedOn));
+        new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository, personIdent)) {
       groupConfig.commit(metaDataUpdate);
     }
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index e7fd1c5..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -25,14 +25,11 @@
  * static utility methods.
  */
 public class CommonConverters {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static GitPerson toGitPerson(PersonIdent ident) {
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.setDate(ident.getWhen().toInstant());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 122e18d..65a81f7 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -50,9 +50,9 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Instant when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -451,21 +451,18 @@
               : ua.preferredEmail();
     }
 
-    return newPersonIdent(name, user, when, tz);
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newCommitterIdent(PersonIdent ident) {
-    return newCommitterIdent(ident.getWhen().toInstant(), ident.getTimeZone());
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
   }
 
-  public PersonIdent newCommitterIdent(Instant when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -500,7 +497,7 @@
       }
     }
 
-    return newPersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
@@ -568,19 +565,4 @@
     }
     return host;
   }
-
-  /**
-   * Create a {@link PersonIdent} from an {@code Instant} and a {@link TimeZone}.
-   *
-   * <p>We use the {@link PersonIdent#PersonIdent(String, String, long, int)} constructor to avoid
-   * doing a conversion to {@code java.util.Date} here. For the {@code int aTZ} argument, which is
-   * the time zone, we do the same computation as in {@link PersonIdent#PersonIdent(String, String,
-   * java.util.Date, TimeZone)} (just instead of getting the epoch millis from {@code
-   * java.util.Date} we get them from {@link Instant}).
-   */
-  // TODO(issue-15517): Drop this method once JGit's PersonIdent class supports Instants
-  private static PersonIdent newPersonIdent(String name, String email, Instant when, TimeZone tz) {
-    return new PersonIdent(
-        name, email, when.toEpochMilli(), tz.getOffset(when.toEpochMilli()) / (60 * 1000));
-  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 28e881e1..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -36,7 +36,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -258,9 +257,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -279,8 +275,8 @@
       }
 
       Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
-      commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(registeredOn)));
-      commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(registeredOn)));
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
 
     saveAccount();
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 3ee6365..137fd59 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -319,17 +319,13 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
     return execute(
             ImmutableList.of(
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
-                  Account account =
-                      accountConfig.getNewAccount(committerIdent.getWhen().toInstant());
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 45f0844..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -113,14 +113,11 @@
     return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(who.getWhen().toInstant());
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..1587bc5 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,7 +122,12 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
   @Override
@@ -83,13 +140,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +171,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +188,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +246,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +258,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +277,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5baed86..f1620cc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -140,6 +141,7 @@
   private final Provider<ListLabels> listLabels;
   private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
+  private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -179,6 +181,7 @@
       Provider<ListLabels> listLabels,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -218,6 +221,7 @@
         listLabels,
         postLabels,
         labelApi,
+        submitRequirementApi,
         null);
   }
 
@@ -259,6 +263,7 @@
       Provider<ListLabels> listLabels,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -298,6 +303,7 @@
         listLabels,
         postLabels,
         labelApi,
+        submitRequirementApi,
         name);
   }
 
@@ -339,6 +345,7 @@
       Provider<ListLabels> listLabels,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -378,6 +385,7 @@
     this.listLabels = listLabels;
     this.postLabels = postLabels;
     this.labelApi = labelApi;
+    this.submitRequirementApi = submitRequirementApi;
   }
 
   @Override
@@ -746,6 +754,15 @@
   }
 
   @Override
+  public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+    try {
+      return submitRequirementApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse submit requirement", e);
+    }
+  }
+
+  @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
       postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
     factory(LabelApiImpl.Factory.class);
+    factory(SubmitRequirementApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..96bdc82
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,113 @@
+// 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.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+  interface Factory {
+    SubmitRequirementApiImpl create(ProjectResource project, String name);
+  }
+
+  private final SubmitRequirementsCollection submitRequirements;
+  private final CreateSubmitRequirement createSubmitRequirement;
+  private final UpdateSubmitRequirement updateSubmitRequirement;
+  private final GetSubmitRequirement getSubmitRequirement;
+  private final String name;
+  private final ProjectCache projectCache;
+
+  private ProjectResource project;
+
+  @Inject
+  SubmitRequirementApiImpl(
+      SubmitRequirementsCollection submitRequirements,
+      CreateSubmitRequirement createSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement,
+      GetSubmitRequirement getSubmitRequirement,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String name) {
+    this.submitRequirements = submitRequirements;
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+    this.getSubmitRequirement = getSubmitRequirement;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.name = name;
+  }
+
+  @Override
+  public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+    try {
+      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+      // recreate project resource because project state was updated
+      project =
+          new ProjectResource(
+              projectCache
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(project.getNameKey())),
+              project.getUser());
+
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo get() throws RestApiException {
+    try {
+      return getSubmitRequirement.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+    try {
+      return updateSubmitRequirement.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirement", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    /** TODO(ghareeb): implement */
+  }
+
+  private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+    return submitRequirements.parse(project, IdString.fromDecoded(name));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
index 4270d1e..ef00b80 100644
--- a/java/com/google/gerrit/server/cache/PerThreadCache.java
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -21,9 +21,7 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import java.util.Map;
-import java.util.Optional;
 import java.util.function.Supplier;
-import javax.servlet.http.HttpServletRequest;
 
 /**
  * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
@@ -60,12 +58,6 @@
   private static final int PER_THREAD_CACHE_SIZE = 25;
 
   /**
-   * Optional HTTP request associated with the per-thread cache, should the thread be associated
-   * with the incoming HTTP thread pool.
-   */
-  private final Optional<HttpServletRequest> httpRequest;
-
-  /**
    * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
    * class and a list of identifiers that in combination uniquely set the object apart form others
    * of the same class.
@@ -110,9 +102,9 @@
     }
   }
 
-  public static PerThreadCache create(@Nullable HttpServletRequest httpRequest) {
+  public static PerThreadCache create() {
     checkState(CACHE.get() == null, "called create() twice on the same request");
-    PerThreadCache cache = new PerThreadCache(httpRequest);
+    PerThreadCache cache = new PerThreadCache();
     CACHE.set(cache);
     return cache;
   }
@@ -129,9 +121,7 @@
 
   private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
 
-  private PerThreadCache(@Nullable HttpServletRequest req) {
-    httpRequest = Optional.ofNullable(req);
-  }
+  private PerThreadCache() {}
 
   /**
    * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
@@ -149,19 +139,6 @@
     return value;
   }
 
-  /** Returns the optional HTTP request associated with the local thread cache. */
-  public Optional<HttpServletRequest> getHttpRequest() {
-    return httpRequest;
-  }
-
-  /** Returns true if there is an HTTP request associated and is a GET or HEAD */
-  public boolean hasReadonlyRequest() {
-    return httpRequest
-        .map(HttpServletRequest::getMethod)
-        .filter(m -> m.equalsIgnoreCase("GET") || m.equalsIgnoreCase("HEAD"))
-        .isPresent();
-  }
-
   @Override
   public void close() {
     CACHE.remove();
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 1cf31c1..1acc91d 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -39,6 +40,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
+  private final AttentionSetObserver attentionSetObserver;
 
   private final Account.Id attentionUserId;
   private final String reason;
@@ -58,12 +60,14 @@
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
       AttentionSetEmail.Factory attentionSetEmailFactory,
+      AttentionSetObserver attentionSetObserver,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
+    this.attentionSetObserver = attentionSetObserver;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
@@ -85,10 +89,13 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    AttentionSetUpdate attentionUpdate =
         AttentionSetUpdate.createForWrite(
-            attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
+            attentionUserId, AttentionSetUpdate.Operation.ADD, reason);
+    changeUpdate.addToPlannedAttentionSetUpdates(attentionUpdate);
+    attentionSetObserver.fire(
+        changeDataFactory.create(change), ctx.getAccount(), attentionUpdate, ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 9fb4fc4..2305791 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -41,6 +42,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
+  private final AttentionSetObserver attentionSetObserver;
 
   private final Account.Id attentionUserId;
   private final String reason;
@@ -60,12 +62,14 @@
       ChangeData.Factory changeDataFactory,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
+      AttentionSetObserver attentionSetObserver,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
+    this.attentionSetObserver = attentionSetObserver;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
     this.notify = notify;
@@ -86,9 +90,12 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
-        AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    AttentionSetUpdate attentionUpdate =
+        AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason);
+    changeUpdate.addToPlannedAttentionSetUpdates(attentionUpdate);
+    attentionSetObserver.fire(
+        changeDataFactory.create(change), ctx.getAccount(), attentionUpdate, ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index a750d8e..c6263e2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -173,6 +174,7 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -188,7 +190,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.FileEditsPredicate;
@@ -312,6 +314,7 @@
     bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
+    factory(DeleteZombieCommentsRefs.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -397,7 +400,7 @@
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
-        .to(SubmitRequirementExpressionsValidator.class);
+        .to(SubmitRequirementConfigValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -454,6 +457,7 @@
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
+    DynamicSet.setOf(binder(), AttentionSetListener.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 232aa6a..2957d6b 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -53,10 +53,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +91,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -110,12 +110,12 @@
       ProjectCache projectCache) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
   }
 
   /**
@@ -519,7 +519,7 @@
 
   private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -709,12 +709,12 @@
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
     }
@@ -841,7 +841,7 @@
 
     private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
new file mode 100644
index 0000000..90ed285
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
@@ -0,0 +1,36 @@
+// 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.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+
+public class ProjectHeadUpdatedEvent extends ProjectEvent {
+
+  static final String TYPE = "project-head-updated";
+
+  public String projectName;
+  public String oldHead;
+  public String newHead;
+
+  public ProjectHeadUpdatedEvent() {
+    super(TYPE);
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return Project.nameKey(projectName);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index abacb85..afe2a7c 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -86,7 +87,8 @@
         ReviewerDeletedListener,
         RevisionCreatedListener,
         TopicEditedListener,
-        VoteDeletedListener {
+        VoteDeletedListener,
+        HeadUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
@@ -111,6 +113,7 @@
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
           .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HeadUpdatedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -339,6 +342,16 @@
   }
 
   @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event ev) {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = ev.getProjectName();
+    event.oldHead = ev.getOldHeadName();
+    event.newHead = ev.getNewHeadName();
+
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
+  }
+
+  @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
new file mode 100644
index 0000000..8f51e13
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -0,0 +1,112 @@
+// 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.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Helper class to fire an event when an attention set changes. */
+@Singleton
+public class AttentionSetObserver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<AttentionSetListener> listeners;
+  private final EventUtil util;
+  private final AccountCache accountCache;
+
+  @Inject
+  AttentionSetObserver(
+      PluginSetContext<AttentionSetListener> listeners, EventUtil util, AccountCache accountCache) {
+    this.listeners = listeners;
+    this.util = util;
+    this.accountCache = accountCache;
+  }
+
+  /**
+   * Notify all listening plugins
+   *
+   * @param changeData is current data of the change
+   * @param accountState is the initiator of the change
+   * @param update is the update that caused the event
+   * @param when is the time of the event
+   */
+  public void fire(
+      ChangeData changeData, AccountState accountState, AttentionSetUpdate update, Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    AccountState target = accountCache.get(update.account()).get();
+
+    HashSet<Integer> added = new HashSet<>();
+    HashSet<Integer> removed = new HashSet<>();
+    switch (update.operation()) {
+      case ADD:
+        added.add(target.account().id().get());
+        break;
+      case REMOVE:
+        removed.add(target.account().id().get());
+        break;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
+      listeners.runEach(l -> l.onAttentionSetChanged(event));
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
+    }
+  }
+
+  /** Event to be fired when an attention set changes */
+  private static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+    private final Set<Integer> added;
+    private final Set<Integer> removed;
+
+    Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        Set<Integer> added,
+        Set<Integer> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public Set<Integer> usersAdded() {
+      return added;
+    }
+
+    @Override
+    public Set<Integer> usersRemoved() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 9cc754c..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -31,8 +31,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,7 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    return currentUser.get().newCommitterIdent(Instant.now(), tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index b04fbf8..fa46bf4 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -222,7 +222,7 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
diff --git a/java/com/google/gerrit/server/git/RefCache.java b/java/com/google/gerrit/server/git/RefCache.java
index 5a5cae9..2dee427 100644
--- a/java/com/google/gerrit/server/git/RefCache.java
+++ b/java/com/google/gerrit/server/git/RefCache.java
@@ -37,4 +37,7 @@
    *     present with a value of {@link ObjectId#zeroId()}.
    */
   Optional<ObjectId> get(String refName) throws IOException;
+
+  /** Closes this cache, releasing the references to any underlying resources. */
+  void close();
 }
diff --git a/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
index 7f22111..d2b3c32 100644
--- a/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.cache.PerThreadCache;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -29,19 +28,11 @@
 public class RepoRefCache implements RefCache {
   private final RefDatabase refdb;
   private final Map<String, Optional<ObjectId>> ids;
-
-  public static Optional<RefCache> getOptional(Repository repo) {
-    PerThreadCache cache = PerThreadCache.get();
-    if (cache != null && cache.hasReadonlyRequest()) {
-      return Optional.of(
-          cache.get(
-              PerThreadCache.Key.create(RepoRefCache.class, repo), () -> new RepoRefCache(repo)));
-    }
-
-    return Optional.empty();
-  }
+  private final Repository repo;
 
   public RepoRefCache(Repository repo) {
+    repo.incrementOpen();
+    this.repo = repo;
     this.refdb = repo.getRefDatabase();
     this.ids = new HashMap<>();
   }
@@ -62,4 +53,9 @@
   public Map<String, Optional<ObjectId>> getCachedRefs() {
     return Collections.unmodifiableMap(ids);
   }
+
+  @Override
+  public void close() {
+    repo.close();
+  }
 }
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 3f7ef2c..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -139,9 +139,6 @@
     return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
@@ -169,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            c.getAuthorIdent().getWhen().toInstant(),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 71cc08c..4f2c049 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -37,7 +37,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.regex.Pattern;
@@ -300,9 +299,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -321,8 +317,8 @@
     Instant commitTimestamp =
         TimeUtil.truncateToSecond(
             groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
-    commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(commitTimestamp)));
-    commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(commitTimestamp)));
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
     InternalGroup updatedGroup = updateGroup(commitTimestamp);
 
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 7029d10..2e75d46 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -58,7 +58,7 @@
           .add(AccountField.ID_STR)
           .build();
 
-  // Bump Lucene version requires reindexing
+  // Upgrade Lucene to 7.x requires reindexing.
   static final Schema<AccountState> V12 = schema(V11);
 
   /**
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index aa08069..ec1506d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -118,9 +118,13 @@
       new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
 
   /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  @Deprecated
   static final Schema<ChangeData> V77 =
       new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ChangeData> V78 = schema(V77);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 773aa9a..91dd285 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -45,7 +45,10 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7);
+  @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<InternalGroup> V9 = schema(V8);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 5cd0e98..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -328,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index ad1703d..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -26,6 +27,7 @@
 import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -137,6 +139,8 @@
     }
 
     // Otherwise load the template as a resource.
-    builder.add(Resources.getResource(logicalPath), logicalPath);
+    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    checkArgument(resource != null, "resource %s not found.", logicalPath);
+    builder.add(resource, logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7efda47..e6f1622 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,9 +55,6 @@
   private ObjectId result;
   boolean rootOnly;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
@@ -66,7 +62,7 @@
       ChangeNoteUtil noteUtil,
       Instant when) {
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = notes.getChange();
     this.accountId = accountId(user);
@@ -76,9 +72,6 @@
     this.when = when;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
@@ -92,7 +85,7 @@
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
@@ -213,9 +206,6 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
@@ -236,7 +226,7 @@
       return null; // Impl is a no-op.
     }
     cb.setAuthor(authorIdent);
-    cb.setCommitter(new PersonIdent(serverIdent, Date.from(when)));
+    cb.setCommitter(new PersonIdent(serverIdent, when));
     setParentCommit(cb, curr);
     if (cb.getTreeId() == null) {
       if (curr.equals(z)) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 69185b1..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
+import com.google.gerrit.json.OptionalTypeAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.JsonElement;
@@ -54,6 +55,7 @@
 
   static Gson newGson() {
     return new GsonBuilder()
+        .registerTypeAdapter(Optional.class, new OptionalTypeAdapter())
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
         .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index e9d2f4c..6f413f1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -24,7 +24,6 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -100,16 +99,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newAccountIdIdent(
       Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
-        Date.from(when),
-        serverIdent.getTimeZone());
+        when,
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 0e5cf11..cc4e9ce 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
-import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -688,10 +687,6 @@
 
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
-    Optional<RefCache> refsCache =
-        Optional.ofNullable(refs).map(Optional::of).orElse(RepoRefCache.getOptional(repo));
-    return refsCache.isPresent()
-        ? refsCache.get().get(getRefName()).orElse(null)
-        : super.readRef(repo);
+    return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 1d8ec82..6700f25 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -1270,11 +1270,8 @@
    * @param commit the commit to return commit time.
    * @return the timestamp when the commit was applied.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant getCommitTimestamp(ChangeNotesCommit commit) {
-    return commit.getCommitterIdent().getWhen().toInstant();
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index e7a8948..da20475 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -578,12 +578,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 28436db..2f17675 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -15,19 +15,30 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,16 +50,20 @@
  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
  *
- * <p>An earlier bug in the deletion of draft comments {@code
- * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
- * and not get deleted. These refs point to an empty tree.
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments {@code
+ *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
+ *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
+ *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
+ *       with the same UUID. For now this runs in logging-only mode and does not remove these zombie
+ *       drafts.
+ * </uL>
  */
 public class DeleteZombieCommentsRefs {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
-  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-
   // Number of refs deleted at once in a batch ref-update.
   // Log progress after deleting every CHUNK_SIZE refs
   private static final int CHUNK_SIZE = 3000;
@@ -56,8 +71,10 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
-  private Repository allUsersRepo;
   private final Consumer<String> uiConsumer;
+  @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
@@ -67,8 +84,18 @@
   public DeleteZombieCommentsRefs(
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
       @Assisted Integer cleanupPercentage) {
-    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil);
   }
 
   public DeleteZombieCommentsRefs(
@@ -76,43 +103,126 @@
       GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
+    this(allUsers, repoManager, cleanupPercentage, uiConsumer, null, null, null);
+  }
+
+  private DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      Consumer<String> uiConsumer,
+      @Nullable ChangeNotes.Factory changeNotesFactory,
+      @Nullable DraftCommentNotes.Factory draftNotesFactory,
+      @Nullable CommentsUtil commentsUtil) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
     this.uiConsumer = uiConsumer;
+    this.draftNotesFactory = draftNotesFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
   }
 
   public void execute() throws IOException {
-    allUsersRepo = repoManager.openRepository(allUsers);
-
-    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
-    List<Ref> zombieRefs = filterZombieRefs(draftRefs);
-
-    logInfo(
-        String.format(
-            "Found a total of %d zombie draft refs in %s repo.",
-            zombieRefs.size(), allUsers.get()));
-
-    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
-    zombieRefs =
-        zombieRefs.stream()
-            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
-            .collect(toImmutableList());
-    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
-
-    long zombieRefsCnt = zombieRefs.size();
-    long deletedRefsCnt = 0;
-    long startTime = System.currentTimeMillis();
-
-    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
-      deleteBatchZombieRefs(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+    deleteDraftRefsThatPointToEmptyTree();
+    if (draftNotesFactory != null) {
+      getNumberOfDraftsThatAreAlsoPublished();
     }
   }
 
-  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
+  private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
+
+      logInfo(
+          String.format(
+              "Found a total of %d zombie draft refs in %s repo.",
+              zombieRefs.size(), allUsers.get()));
+
+      logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
+      zombieRefs =
+          zombieRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
+
+      long zombieRefsCnt = zombieRefs.size();
+      long deletedRefsCnt = 0;
+      long startTime = System.currentTimeMillis();
+
+      for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
+        deleteBatchZombieRefs(allUsersRepo, refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      }
+    }
+  }
+
+  /**
+   * For each draft comment, check if there exists a published comment with the same UUID and log a
+   * warning if that's the case.
+   */
+  @VisibleForTesting
+  public int getNumberOfDraftsThatAreAlsoPublished() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      int numZombies = 0;
+      Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+      for (Ref draftRef : draftRefs) {
+        try {
+          Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
+          Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+          ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+          if (!visitedSet.add(changeUserIDsPair)) {
+            continue;
+          }
+          DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
+          ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(changeId);
+          Set<String> draftIds = toUuid(draftNotes.getComments().values().asList());
+          Set<String> publishedIds = toUuid(commentsUtil.publishedHumanCommentsByChange(notes));
+          List<String> zombieIds =
+              draftIds.stream()
+                  .filter(zombieId -> publishedIds.contains(zombieId))
+                  .collect(Collectors.toList());
+          zombieIds.forEach(
+              zombieId ->
+                  logger.atWarning().log(
+                      "Draft comment with uuid '%s' of change %s"
+                          + " is a zombie draft that is already published.",
+                      zombieId, changeId));
+          numZombies += zombieIds.size();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
+        }
+      }
+      if (numZombies > 0) {
+        logger.atWarning().log("Detected %d additional zombie drafts.", numZombies);
+      }
+      return numZombies;
+    }
+  }
+
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
+  }
+
+  private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
+      throws IOException {
     List<ReceiveCommand> deleteCommands =
         refsBatch.stream()
             .map(
@@ -126,18 +236,19 @@
     RefUpdateUtil.executeChecked(bru, allUsersRepo);
   }
 
-  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
+  private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
+      throws IOException {
     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     for (Ref ref : allDraftRefs) {
-      if (isZombieRef(ref)) {
+      if (isZombieRef(allUsersRepo, ref)) {
         zombieRefs.add(ref);
       }
     }
     return zombieRefs;
   }
 
-  private boolean isZombieRef(Ref ref) throws IOException {
-    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
+  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
+    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
   private void logInfo(String message) {
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 506d292..12a7841 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -130,11 +130,10 @@
                   notesResult -> {
                     if (!notesResult.error().isPresent()) {
                       return changeDataFactory.create(notesResult.notes());
-                    } else {
-                      logger.atWarning().withCause(notesResult.error().get()).log(
-                          "Unable to load ChangeNotes for %s", notesResult.id());
-                      return null;
                     }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
                   })
               .filter(Objects::nonNull);
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index d816d84..7ace1c8 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -550,6 +550,7 @@
     return submitRequirementSections;
   }
 
+  /** Adds or replaces the given {@link SubmitRequirement} in this config. */
   public void upsertSubmitRequirement(SubmitRequirement requirement) {
     submitRequirementSections.put(requirement.name(), requirement);
   }
@@ -1018,7 +1019,7 @@
         continue;
       }
 
-      // The expressions are validated in SubmitRequirementExpressionsValidator.
+      // The expressions are validated in SubmitRequirementConfigValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
new file mode 100644
index 0000000..faca446
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -0,0 +1,135 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementConfigValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  SubmitRequirementConfigValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList.Builder<String> validationMsgsBuilder = ImmutableList.builder();
+      for (SubmitRequirement submitRequirement :
+          projectConfig.getSubmitRequirementSections().values()) {
+        validationMsgsBuilder.addAll(
+            submitRequirementExpressionsValidator.validateExpressions(submitRequirement));
+      }
+      ImmutableList<String> validationMsgs = validationMsgsBuilder.build();
+      if (!validationMsgs.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+            new ImmutableList.Builder<CommitValidationMessage>()
+                .add(
+                    new CommitValidationMessage(
+                        "Invalid project configuration", ValidationMessage.Type.ERROR))
+                .addAll(
+                    validationMsgs.stream()
+                        .map(m -> toCommitValidationMessage(m))
+                        .collect(Collectors.toList()))
+                .build());
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  private static CommitValidationMessage toCommitValidationMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
index 8717581..f2e4ff8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -15,144 +15,59 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.inject.Inject;
-import java.io.IOException;
+import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
-/**
- * Validates the expressions of submit requirements in {@code project.config}.
- *
- * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
- * ProjectConfig#loadSubmitRequirementSections(Config)}.
- *
- * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
- * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
- * {@link ProjectConfig} is cached in the project cache).
- */
-public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
-  private final DiffOperations diffOperations;
-  private final ProjectConfig.Factory projectConfigFactory;
+@Singleton
+public class SubmitRequirementExpressionsValidator {
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Inject
-  SubmitRequirementExpressionsValidator(
-      DiffOperations diffOperations,
-      ProjectConfig.Factory projectConfigFactory,
-      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
-    this.diffOperations = diffOperations;
-    this.projectConfigFactory = projectConfigFactory;
+  SubmitRequirementExpressionsValidator(SubmitRequirementsEvaluator submitRequirementsEvaluator) {
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
   }
 
-  @Override
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
-      throws CommitValidationException {
-    try {
-      if (!event.refName.equals(RefNames.REFS_CONFIG)
-          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
-        // the project.config file in refs/meta/config was not modified, hence we do not need to
-        // validate the submit requirements in it
-        return ImmutableList.of();
-      }
-
-      ProjectConfig projectConfig = getProjectConfig(event);
-      ImmutableList<CommitValidationMessage> validationMessages =
-          validateSubmitRequirementExpressions(
-              projectConfig.getSubmitRequirementSections().values());
-      if (!validationMessages.isEmpty()) {
-        throw new CommitValidationException(
-            String.format(
-                "invalid submit requirement expressions in %s (revision = %s)",
-                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
-            validationMessages);
-      }
-      return ImmutableList.of();
-    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
-      throw new CommitValidationException(
-          String.format(
-              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
-                  + " of project %s",
-              ProjectConfig.PROJECT_CONFIG,
-              event.commit.getName(),
-              RefNames.REFS_CONFIG,
-              event.project.getNameKey()),
-          e);
-    }
-  }
-
   /**
-   * Whether the given file was changed in the given revision.
+   * Validates the query expressions on the input {@code submitRequirement}.
    *
-   * @param receiveEvent the receive event
-   * @param fileName the name of the file
+   * @return list of string containing the error messages resulting from the validation. The list is
+   *     empty if the "submit requirement" is valid.
    */
-  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
-      throws DiffNotAvailableException {
-    return diffOperations
-        .listModifiedFilesAgainstParent(
-            receiveEvent.project.getNameKey(),
-            receiveEvent.commit,
-            /* parentNum=*/ 0,
-            DiffOptions.DEFAULTS)
-        .keySet().stream()
-        .anyMatch(fileName::equals);
-  }
-
-  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
-    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
-    return projectConfig;
-  }
-
-  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
-      Collection<SubmitRequirement> submitRequirements) {
-    List<CommitValidationMessage> validationMessages = new ArrayList<>();
-    for (SubmitRequirement submitRequirement : submitRequirements) {
-      validateSubmitRequirementExpression(
-          validationMessages,
-          submitRequirement,
-          submitRequirement.submittabilityExpression(),
-          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
-      submitRequirement
-          .applicabilityExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
-      submitRequirement
-          .overrideExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
-    }
+  public ImmutableList<String> validateExpressions(SubmitRequirement submitRequirement) {
+    List<String> validationMessages = new ArrayList<>();
+    validateSubmitRequirementExpression(
+        validationMessages,
+        submitRequirement,
+        submitRequirement.submittabilityExpression(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+    submitRequirement
+        .applicabilityExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+    submitRequirement
+        .overrideExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
     return ImmutableList.copyOf(validationMessages);
   }
 
   private void validateSubmitRequirementExpression(
-      List<CommitValidationMessage> validationMessages,
+      List<String> validationMessages,
       SubmitRequirement submitRequirement,
       SubmitRequirementExpression expression,
       String configKey) {
@@ -160,23 +75,19 @@
       submitRequirementsEvaluator.validateExpression(expression);
     } catch (QueryParseException e) {
       if (validationMessages.isEmpty()) {
-        validationMessages.add(
-            new CommitValidationMessage(
-                "Invalid project configuration", ValidationMessage.Type.ERROR));
+        validationMessages.add("Invalid project configuration");
       }
       validationMessages.add(
-          new CommitValidationMessage(
-              String.format(
-                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
-                      + " invalid: %s",
-                  ProjectConfig.PROJECT_CONFIG,
-                  expression.expressionString(),
-                  submitRequirement.name(),
-                  ProjectConfig.SUBMIT_REQUIREMENT,
-                  submitRequirement.name(),
-                  configKey,
-                  e.getMessage()),
-              ValidationMessage.Type.ERROR));
+          String.format(
+              "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                  + " invalid: %s",
+              ProjectConfig.PROJECT_CONFIG,
+              expression.expressionString(),
+              submitRequirement.name(),
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              submitRequirement.name(),
+              configKey,
+              e.getMessage()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementJson.java b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
new file mode 100644
index 0000000..5593ff4
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
@@ -0,0 +1,38 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.inject.Singleton;
+
+/** Converts a {@link SubmitRequirement} to a {@link SubmitRequirementInfo}. */
+@Singleton
+public class SubmitRequirementJson {
+  public static SubmitRequirementInfo format(SubmitRequirement sr) {
+    SubmitRequirementInfo info = new SubmitRequirementInfo();
+    info.name = sr.name();
+    info.description = sr.description().orElse(null);
+    if (sr.applicabilityExpression().isPresent()) {
+      info.applicabilityExpression = sr.applicabilityExpression().get().expressionString();
+    }
+    if (sr.overrideExpression().isPresent()) {
+      info.overrideExpression = sr.overrideExpression().get().expressionString();
+    }
+    info.submittabilityExpression = sr.submittabilityExpression().expressionString();
+    info.allowOverrideInChildProjects = sr.allowOverrideInChildProjects();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementResource.java b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
new file mode 100644
index 0000000..d075cd7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
@@ -0,0 +1,41 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementResource implements RestResource {
+  public static final TypeLiteral<RestView<SubmitRequirementResource>> SUBMIT_REQUIREMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final ProjectResource project;
+  private final SubmitRequirement submitRequirement;
+
+  public SubmitRequirementResource(ProjectResource project, SubmitRequirement submitRequirement) {
+    this.project = project;
+    this.submitRequirement = submitRequirement;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public SubmitRequirement getSubmitRequirement() {
+    return submitRequirement;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index c234c8c..3789c42 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Counter2;
@@ -29,6 +30,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 /**
@@ -38,6 +40,12 @@
 @Singleton
 public class SubmitRequirementsUtil {
 
+  /**
+   * Submit requirement name can only contain alphanumeric characters or hyphen. Name cannot start
+   * with a hyphen or number.
+   */
+  private static final Pattern SUBMIT_REQ_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9\\-]*");
+
   @Singleton
   static class Metrics {
     final Counter2<String, String> submitRequirementsMatchingWithLegacy;
@@ -179,6 +187,20 @@
     return ImmutableMap.copyOf(result);
   }
 
+  /** Validates the name of submit requirements. */
+  public static void validateName(@Nullable String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty submit requirement name");
+    }
+    if (!SUBMIT_REQ_NAME_PATTERN.matcher(name).matches()) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Illegal submit requirement name \"%s\". Name can only consist of "
+                  + "alphanumeric characters and '-'. Name cannot start with '-' or number.",
+              name));
+    }
+  }
+
   private static boolean shouldReportMetric(ChangeData cd) {
     // We only care about recording differences in old and new requirements for open changes
     // that did not have their data retrieved from the (potentially stale) change index.
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 232cd77..00c48dc 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -70,11 +70,11 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -104,7 +104,7 @@
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<IdentifiedUser> user;
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -135,7 +135,7 @@
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -307,7 +307,7 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
 
       try {
         MergeUtil mergeUtil;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index e668891..8c56967 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -86,11 +86,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -118,7 +117,7 @@
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
@@ -158,7 +157,7 @@
     this.anonymousCowardName = anonymousCowardName;
     this.gitManager = gitManager;
     this.seq = seq;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.projectsCollection = projectsCollection;
@@ -321,9 +320,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ChangeInfo createNewChange(
       ChangeInput input,
       IdentifiedUser me,
@@ -360,12 +356,11 @@
 
       Instant now = TimeUtil.now();
 
-      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(
-                  input.author.name, input.author.email, Date.from(now), serverTimeZone);
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 7259deb..4b66cdc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -69,9 +69,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -86,7 +85,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
@@ -113,7 +112,7 @@
     this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
@@ -124,9 +123,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, MergePatchSetInput in)
       throws IOException, RestApiException, UpdateException, PermissionBackendException {
@@ -185,8 +181,8 @@
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
-              ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, Date.from(now), serverTimeZone);
+              ? me.newCommitterIdent(now, serverZoneId)
+              : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c62200a..f898dca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -48,7 +48,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,7 +64,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
@@ -86,7 +86,7 @@
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
@@ -167,7 +167,8 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(
+        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 842f4b9..f991e17 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.FieldBundle;
@@ -235,7 +236,10 @@
     return suggestedReviewers;
   }
 
-  private static Account.Id fromIdField(FieldBundle f) {
+  private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
+    if (useLegacyNumericFields) {
+      return Account.id(f.getValue(AccountField.ID).intValue());
+    }
     return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
   }
 
@@ -251,6 +255,10 @@
               accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
+      boolean useLegacyNumericFields =
+          accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID);
+      FieldDef<AccountState, ?> idField =
+          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
       ResultSet<FieldBundle> result =
           accountIndexes
               .getSearchIndex()
@@ -260,10 +268,12 @@
                       indexConfig,
                       0,
                       suggestReviewers.getLimit(),
-                      ImmutableSet.of(AccountField.ID_STR.getName())))
+                      ImmutableSet.of(idField.getName())))
               .readRaw();
       List<Account.Id> matches =
-          result.toList().stream().map(f -> fromIdField(f)).collect(toList());
+          result.toList().stream()
+              .map(f -> fromIdField(f, useLegacyNumericFields))
+              .collect(toList());
       logger.atFine().log("Matches: %s", matches);
       return matches;
     } catch (TooManyTermsInQueryException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 41fecaf..1d5064e 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -154,9 +154,6 @@
     return ImmutableList.of();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ImmutableList<RevisionResource> loadEdit(
       ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
@@ -167,7 +164,7 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(editCommit.getCommitterIdent().getWhen().toInstant())
+              .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
         return ImmutableList.of(new RevisionResource(change, ps, edit));
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index f257f86..e617931 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -61,13 +61,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -77,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -102,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverTimeZone = serverIdent.get().getTimeZone();
+    this.serverZoneId = serverIdent.get().getZoneId();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -212,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.now(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverZoneId)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
new file mode 100644
index 0000000..2aeba89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** A rest create view that creates a "submit requirement" for a project. */
+@Singleton
+public class CreateSubmitRequirement
+    implements RestCollectionCreateView<
+        ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public CreateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      ProjectResource rsrc, IdString id, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, IOException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
+
+      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    validateSRName(name);
+    ensureSRUnique(name, config);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  private void ensureSRUnique(String name, ProjectConfig config) throws ResourceConflictException {
+    for (String srName : config.getSubmitRequirementSections().keySet()) {
+      if (srName.equalsIgnoreCase(name)) {
+        throw new ResourceConflictException(
+            String.format(
+                "submit requirement \"%s\" conflicts with existing submit requirement \"%s\"",
+                name, srName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 6980006..c5013f7 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -44,7 +44,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -136,7 +136,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.now(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index e0131ee..967b3c5 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -89,9 +89,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
@@ -118,7 +115,7 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Instant timestamp = e.getWho().getWhen().toInstant();
+          Instant timestamp = e.getWho().getWhenAsInstant();
           if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
new file mode 100644
index 0000000..ce482e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Singleton;
+
+/** A rest read view that retrieves a "submit requirement" for a project by its name. */
+@Singleton
+public class GetSubmitRequirement implements RestReadView<SubmitRequirementResource> {
+  @Override
+  public Response<SubmitRequirementInfo> apply(SubmitRequirementResource rsrc) {
+    return Response.ok(SubmitRequirementJson.format(rsrc.getSubmitRequirement()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index eccdcfc..ac0dff9 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -172,9 +172,6 @@
     throw new ResourceNotFoundException(id);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static TagInfo createTagInfo(
       PermissionBackend.ForRef perm, Ref ref, RevWalk rw, ProjectState projectState, WebLinks links)
       throws IOException {
@@ -200,12 +197,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? tagger.getWhen().toInstant() : null);
+          tagger != null ? tagger.getWhenAsInstant() : null);
     }
 
     Instant timestamp =
         object instanceof RevCommit
-            ? ((RevCommit) object).getCommitterIdent().getWhen().toInstant()
+            ? ((RevCommit) object).getCommitterIdent().getWhenAsInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index e50a494..1752b4ec 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
 import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
     DynamicMap.mapOf(binder(), LABEL_KIND);
+    DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
     DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
@@ -78,6 +80,11 @@
     delete(LABEL_KIND).to(DeleteLabel.class);
     postOnCollection(LABEL_KIND).to(PostLabels.class);
 
+    child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
+    create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
+    put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
+    get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
new file mode 100644
index 0000000..cd98bec
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubmitRequirementsCollection
+    implements ChildCollection<ProjectResource, SubmitRequirementResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<SubmitRequirementResource>> views;
+
+  @Inject
+  SubmitRequirementsCollection(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<SubmitRequirementResource>> views) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.views = views;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    /** TODO(ghareeb): implement. */
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public SubmitRequirementResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+
+    SubmitRequirement submitRequirement =
+        parent.getProjectState().getConfig().getSubmitRequirementSections().get(id.get());
+
+    if (submitRequirement == null) {
+      throw new ResourceNotFoundException(
+          String.format("Submit requirement '%s' does not exist", id));
+    }
+    return new SubmitRequirementResource(parent, submitRequirement);
+  }
+
+  @Override
+  public DynamicMap<RestView<SubmitRequirementResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
new file mode 100644
index 0000000..a176bc4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest modify view that updates the definition of an existing submit requirement for a project.
+ */
+@Singleton
+public class UpdateSubmitRequirement
+    implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public UpdateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      SubmitRequirementResource rsrc, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, PermissionBackendException, IOException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(rsrc.getSubmitRequirement().name())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement =
+          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+
+      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    validateSRName(name);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 58db331..1a49171 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -72,8 +71,6 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -247,7 +244,6 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
-  private final ProjectCache projectCache;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -282,8 +278,7 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
-      ProjectCache projectCache) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -301,7 +296,6 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
-    this.projectCache = projectCache;
   }
 
   @Override
@@ -654,12 +648,12 @@
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
         ChangeData cd = entry.getValue();
-        Collection<SubmitRequirementResult> srResults =
-            cd.submitRequirementsIncludingLegacy().values();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create(srResults, cd));
-        crossCheckSubmitRequirementResults(cd, srResults, project);
+            .addOp(
+                changeId,
+                storeSubmitRequirementsOpFactory.create(
+                    cd.submitRequirementsIncludingLegacy().values(), cd));
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -1009,28 +1003,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  /**
-   * Make sure that for every project config submit requirement there exists a corresponding result
-   * with the same name in {@code srResults}. If no result is found, log a warning message.
-   */
-  private void crossCheckSubmitRequirementResults(
-      ChangeData cd, Collection<SubmitRequirementResult> srResults, Project.NameKey project) {
-    ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
-    for (String srName : projectConfigRequirements.keySet()) {
-      boolean hasResult = false;
-      for (SubmitRequirementResult srResult : srResults) {
-        if (!srResult.isLegacy() && srResult.submitRequirement().name().equals(srName)) {
-          hasResult = true;
-          break;
-        }
-      }
-      if (!hasResult) {
-        logger.atWarning().log(
-            "Change %d: No result found for project config submit requirement '%s'",
-            cd.getId().get(), srName);
-      }
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index b247552..3dfd9b4 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -69,6 +69,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -76,7 +77,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.TreeMap;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -257,8 +257,8 @@
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -383,7 +383,7 @@
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Instant when;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
@@ -422,7 +422,7 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -666,7 +666,7 @@
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
             dryrun);
     if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
     }
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
diff --git a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
index 99c72f2..5ff8d33 100644
--- a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
+++ b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
@@ -39,13 +39,19 @@
 public class ChainedReceiveCommands implements RefCache {
   private final Map<String, ReceiveCommand> commands = new LinkedHashMap<>();
   private final RepoRefCache refCache;
+  private final boolean closeRefCache;
 
   public ChainedReceiveCommands(Repository repo) {
-    this(new RepoRefCache(repo));
+    this(new RepoRefCache(repo), true);
   }
 
   public ChainedReceiveCommands(RepoRefCache refCache) {
+    this(refCache, false);
+  }
+
+  private ChainedReceiveCommands(RepoRefCache refCache, boolean closeRefCache) {
     this.refCache = requireNonNull(refCache);
+    this.closeRefCache = closeRefCache;
   }
 
   public RepoRefCache getRepoRefCache() {
@@ -122,4 +128,11 @@
   public Map<String, ReceiveCommand> getCommands() {
     return Collections.unmodifiableMap(commands);
   }
+
+  @Override
+  public void close() {
+    if (closeRefCache) {
+      refCache.close();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 57ebedd..aa41d90 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -70,13 +70,13 @@
   Instant getWhen();
 
   /**
-   * Get the time zone in which this update takes place.
+   * Get the time zone ID in which this update takes place.
    *
-   * <p>In the current implementation, this is always the time zone of the server.
+   * <p>In the current implementation, this is always the time zone ID of the server.
    *
-   * @return time zone.
+   * @return zone ID.
    */
-  TimeZone getTimeZone();
+  ZoneId getZoneId();
 
   /**
    * Get the user performing the update.
@@ -162,6 +162,6 @@
    * @return the created committer {@link PersonIdent}
    */
   default PersonIdent newCommitterIdent(IdentifiedUser user) {
-    return user.newCommitterIdent(getWhen(), getTimeZone());
+    return user.newCommitterIdent(getWhen(), getZoneId());
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b9daa13..86ceb60 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
@@ -243,13 +244,19 @@
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    String indexTypeCfg = cfg.getString("index", null, "type");
-    IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
-    // For custom index types, callers must provide their own module.
-    if (indexType.isLucene()) {
-      install(luceneIndexModule());
-    } else if (indexType.isFake()) {
-      install(fakeIndexModule());
+    // Index lib module has a higher priority than index type configuration.
+    String indexModule =
+        cfg.getString("index", null, "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey());
+    if (indexModule != null) {
+      install(indexModule(indexModule));
+    } else {
+      String indexTypeCfg = cfg.getString("index", null, "type");
+      IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
+      if (indexType.isLucene()) {
+        install(luceneIndexModule());
+      } else if (indexType.isFake()) {
+        install(fakeIndexModule());
+      }
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 8bd02b8..4a97bc5 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -109,7 +109,7 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 877ccd5..b6e5b74 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
-import java.util.Date;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
@@ -333,13 +332,10 @@
     assertThat(repo.exactRef(ref.getName())).isNull();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 78a0eeb..9f47925 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -158,7 +158,6 @@
 import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -2466,9 +2465,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
@@ -2482,7 +2478,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c5f0d23..e8ef187 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -83,6 +83,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.api.change.ChangeIT.TestAttentionSetListenerModule.TestAttentionSetListener;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -147,7 +148,9 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -1536,6 +1539,39 @@
   }
 
   @Test
+  public void attentionSetListener_firesOnChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
+    TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(attentionSetListener)) {
+
+      gApi.changes().id(r1.getChangeId()).addReviewer(user.email());
+      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+
+      assertThat(attentionSetListener.fired).isTrue();
+      assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersAdded()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+      assertThat(attentionSetListener.lastEvent.usersRemoved()).isEmpty();
+
+      attentionSetListener.fired = false;
+      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+
+      assertThat(attentionSetListener.fired).isTrue();
+      assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
+      assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersRemoved()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+    }
+  }
+
+  @Test
   public void rebaseChangeBase() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = createChange();
@@ -4752,6 +4788,27 @@
     }
   }
 
+  public static class TestAttentionSetListenerModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), AttentionSetListener.class).to(TestAttentionSetListener.class);
+    }
+
+    public static class TestAttentionSetListener implements AttentionSetListener {
+      Event lastEvent;
+      boolean fired;
+
+      @Inject
+      public TestAttentionSetListener() {}
+
+      @Override
+      public void onAttentionSetChanged(Event event) {
+        fired = true;
+        lastEvent = event;
+      }
+    }
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 63b67f8..d630296 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -112,7 +112,6 @@
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
@@ -1608,9 +1607,6 @@
     return createCommit(repo, commitMessage, null);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo, String commitMessage, @Nullable ObjectId treeId)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
@@ -1618,7 +1614,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
new file mode 100644
index 0000000..b8f4f42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -0,0 +1,471 @@
+// 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.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsAPIIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotGetANonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void getExistingSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").get();
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isEqualTo("topic:foo");
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+2");
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void updateSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.submittabilityExpression = "label:code-review=+1";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+1");
+  }
+
+  @Test
+  public void updateSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.applicabilityExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void updateSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.overrideExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_updateSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = "topic:foo";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.allowOverrideInChildProjects).isFalse();
+  }
+
+  @Test
+  public void cannotUpdateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "label:code-review=+1";
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .update(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotUpdateSRtIfSRDoesNotExist() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void cannotUpdateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void createSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+    input.allowOverrideInChildProjects = true;
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.submittabilityExpression).isEqualTo(input.submittabilityExpression);
+    assertThat(info.overrideExpression).isEqualTo(input.overrideExpression);
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(true);
+  }
+
+  @Test
+  public void createSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void createSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_createSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void cannotCreateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotCreateSRtIfNameInInputDoesNotMatchResource() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("other-requirement")
+                    .create(input)
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidName() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "wrong$%";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("wrong$%")
+                    .create(input)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Illegal submit requirement name \"wrong$%\". "
+                + "Name can only consist of alphanumeric characters and '-'."
+                + " Name cannot start with '-' or number.");
+  }
+
+  @Test
+  public void cannotCreateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index efd3cea..1919810 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -3005,9 +3005,6 @@
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
@@ -3030,14 +3027,14 @@
       DateTimeFormatter fmt =
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
               .withLocale(Locale.US)
-              .withZone(author.getTimeZone().toZoneId());
+              .withZone(author.getZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + fmt.format(author.getWhen().toInstant()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhenAsInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      fmt = fmt.withZone(committer.getTimeZone().toZoneId());
+      fmt = fmt.withZone(committer.getZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + fmt.format(committer.getWhen().toInstant()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhenAsInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 4a7849f..2c80333 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1706,15 +1706,13 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
   @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhen().getTime());
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f1c0110..8879d53 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -85,7 +87,8 @@
               .build(),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.put("/projects/%s/labels/new-label"),
-          RestCall.post("/projects/%s/labels/"));
+          RestCall.post("/projects/%s/labels/"),
+          RestCall.put("/projects/%s/submit_requirements/new-sr"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -175,6 +178,15 @@
           // Label deletion must be tested last
           RestCall.delete("/projects/%s/labels/%s"));
 
+  /**
+   * Submit requirement REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the submit requirement name.
+   */
+  private static final ImmutableList<RestCall> SUBMIT_REQUIREMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/submit_requirements/%s"),
+          RestCall.put("/projects/%s/submit_requirements/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -236,6 +248,20 @@
     RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
   }
 
+  @Test
+  public void submitRequirementsEndpoints() throws Exception {
+    // Create the SR, so that the GET endpoint succeeds
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    RestApiCallHelper.execute(
+        adminRestSession, SUBMIT_REQUIREMENT_ENDPOINTS, project.get(), "code-review");
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 8eada79..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1361,14 +1361,11 @@
     assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen().getTime())
-        .isEqualTo(commit.getCommitterIdent().getWhen().getTime());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+    assertThat(commit.getAuthorIdent().getWhenAsInstant())
+        .isEqualTo(commit.getCommitterIdent().getWhenAsInstant());
+    assertThat(commit.getAuthorIdent().getZoneId())
+        .isEqualTo(commit.getCommitterIdent().getZoneId());
   }
 
   protected void assertSubmitter(String changeId, int psId) throws Throwable {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 044da19..c8c00a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -457,11 +457,8 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant instant(PushOneCommit.Result r) {
-    return r.getCommit().getCommitterIdent().getWhen().toInstant();
+    return r.getCommit().getCommitterIdent().getWhenAsInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 5b6da36..bcde618 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -825,14 +825,11 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
         noteUtil.newAccountIdIdent(
-            getAccount(admin.id()).id(), committer.getWhen().toInstant(), committer);
+            getAccount(admin.id()).id(), committer.getWhenAsInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
new file mode 100644
index 0000000..3d83175
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -0,0 +1,95 @@
+// 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.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.notedb.DeleteZombieCommentsRefs}. */
+public class DeleteZombieDraftIT extends AbstractDaemonTest {
+
+  @Inject private DeleteZombieCommentsRefs.Factory deleteZombieDraftsFactory;
+
+  @Test
+  public void detectZombieDrafts() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    DraftInput comment = CommentsUtil.newDraft("f1.txt", Side.REVISION, /* line= */ 1, "comment 1");
+    addDraft(changeId, revId, comment);
+    Ref draftRef = getOnlyDraftRef();
+    publishComments(r);
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    List<CommentInfo> publishedComments = getPublishedCommentsAsList(changeId);
+    assertThat(drafts).isEmpty();
+    assertThat(publishedComments).hasSize(1);
+
+    // Restore the draft ref, resulting in the comment existing twice as {draft, published}.
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      RefUpdate u = allUsersRepo.updateRef(draftRef.getName());
+      u.setNewObjectId(draftRef.getObjectId());
+      u.forceUpdate();
+    }
+
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100);
+    assertThat(worker.getNumberOfDraftsThatAreAlsoPublished()).isEqualTo(1);
+  }
+
+  private Ref getOnlyDraftRef() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      return Iterables.getOnlyElement(
+          allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS));
+    }
+  }
+
+  private void publishComments(PushOneCommit.Result r) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    revision(r).review(reviewInput);
+  }
+
+  private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
+      throws Exception {
+    return gApi.changes().id(changeId).revision(revId).drafts();
+  }
+
+  private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private void addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index c04deb4..1bb9784 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -18,10 +18,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import java.util.function.Supplier;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletRequestWrapper;
 import org.junit.Test;
 
 public class PerThreadCacheTest {
@@ -47,7 +44,7 @@
 
   @Test
   public void endToEndCache() {
-    try (PerThreadCache ignored = PerThreadCache.create(null)) {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
       PerThreadCache cache = PerThreadCache.get();
       PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
 
@@ -65,7 +62,7 @@
   @Test
   public void cleanUp() {
     PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
-    try (PerThreadCache ignored = PerThreadCache.create(null)) {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
       PerThreadCache cache = PerThreadCache.get();
       String value1 = cache.get(key, () -> "value1");
       assertThat(value1).isEqualTo("value1");
@@ -73,7 +70,7 @@
 
     // Create a second cache and assert that it is not connected to the first one.
     // This ensures that the cleanup is actually working.
-    try (PerThreadCache ignored = PerThreadCache.create(null)) {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
       PerThreadCache cache = PerThreadCache.get();
       String value1 = cache.get(key, () -> "value2");
       assertThat(value1).isEqualTo("value2");
@@ -82,48 +79,16 @@
 
   @Test
   public void doubleInstantiationFails() {
-    try (PerThreadCache ignored = PerThreadCache.create(null)) {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
       IllegalStateException thrown =
-          assertThrows(IllegalStateException.class, () -> PerThreadCache.create(null));
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
       assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
     }
   }
 
   @Test
-  public void isAssociatedWithHttpReadonlyRequest() {
-    HttpServletRequest getRequest = new FakeHttpServletRequest();
-    try (PerThreadCache cache = PerThreadCache.create(getRequest)) {
-      assertThat(cache.getHttpRequest()).hasValue(getRequest);
-      assertThat(cache.hasReadonlyRequest()).isTrue();
-    }
-  }
-
-  @Test
-  public void isAssociatedWithHttpWriteRequest() {
-    HttpServletRequest putRequest =
-        new HttpServletRequestWrapper(new FakeHttpServletRequest()) {
-          @Override
-          public String getMethod() {
-            return "PUT";
-          }
-        };
-    try (PerThreadCache cache = PerThreadCache.create(putRequest)) {
-      assertThat(cache.getHttpRequest()).hasValue(putRequest);
-      assertThat(cache.hasReadonlyRequest()).isFalse();
-    }
-  }
-
-  @Test
-  public void isNotAssociatedWithHttpRequest() {
-    try (PerThreadCache cache = PerThreadCache.create(null)) {
-      assertThat(cache.getHttpRequest()).isEmpty();
-      assertThat(cache.hasReadonlyRequest()).isFalse();
-    }
-  }
-
-  @Test
   public void enforceMaxSize() {
-    try (PerThreadCache cache = PerThreadCache.create(null)) {
+    try (PerThreadCache cache = PerThreadCache.create()) {
       // Fill the cache
       for (int i = 0; i < 50; i++) {
         PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 3c9a355..c2b67c3 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -593,6 +593,24 @@
                 .build());
   }
 
+  @Test
+  public void projectHeadUpdatedEvent() {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = PROJECT;
+    event.oldHead = "refs/heads/master";
+    event.newHead = REF;
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put("projectName", PROJECT)
+                .put("oldHead", "refs/heads/master")
+                .put("newHead", REF)
+                .put("type", "project-head-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
   private Supplier<AccountAttribute> newAccount(String name) {
     AccountAttribute account = new AccountAttribute();
     account.name = name;
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 29dbe58..6bdf80f 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -60,7 +59,8 @@
       Ref ref2 = createRefWithEmptyTreeCommit(usersRepo, 1, 1000002);
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       /* Check that ref1 still exists, and ref2 is deleted */
@@ -81,7 +81,7 @@
       int cleanupPercentage = 50;
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
       clean.execute();
 
       /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
@@ -101,7 +101,7 @@
       cleanupPercentage = 70;
       clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
 
       clean.execute();
 
@@ -137,7 +137,8 @@
           .isEqualTo(goodRefs.size() + badRefs.size());
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       assertThat(
@@ -204,14 +205,11 @@
     return repo.exactRef(refName);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommit(Repository repo, ObjectId treeId, ObjectId parentCommit)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
new file mode 100644
index 0000000..2bc6b92
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
@@ -0,0 +1,274 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public class RepoRefCacheTest {
+  private static final String TEST_BRANCH = "main";
+
+  @Test
+  @SuppressWarnings("resource")
+  public void repositoryUseShouldBeTrackedByRepoRefCache() throws Exception {
+    RefCache cache;
+    TestRepositoryWithRefCounting repoWithRefCounting;
+
+    try (TestRepositoryWithRefCounting repo =
+        TestRepositoryWithRefCounting.createWithBranch(TEST_BRANCH)) {
+      assertThat(repo.refCounter()).isEqualTo(1);
+      repoWithRefCounting = repo;
+      cache = new RepoRefCache(repo);
+    }
+
+    assertThat(repoWithRefCounting.refCounter()).isEqualTo(1);
+    assertThat(cache.get(Constants.R_HEADS + TEST_BRANCH)).isNotNull();
+  }
+
+  private static class TestRepositoryWithRefCounting extends Repository {
+    private int refCounter;
+
+    static TestRepositoryWithRefCounting createWithBranch(String branchName) throws Exception {
+      InMemoryRepository.Builder builder =
+          new InMemoryRepository.Builder()
+              .setRepositoryDescription(new DfsRepositoryDescription(""))
+              .setFS(FS.detect().setUserHome(null));
+      TestRepositoryWithRefCounting testRepo = new TestRepositoryWithRefCounting(builder);
+      new TestRepository<>(testRepo).branch(branchName).commit().message("").create();
+      return testRepo;
+    }
+
+    private final Repository repo;
+
+    private TestRepositoryWithRefCounting(InMemoryRepository.Builder builder) throws IOException {
+      super(builder);
+
+      repo = builder.build();
+      refCounter = 1;
+    }
+
+    public int refCounter() {
+      return refCounter;
+    }
+
+    @Override
+    public void incrementOpen() {
+      repo.incrementOpen();
+      refCounter++;
+    }
+
+    @Override
+    public void close() {
+      repo.close();
+      refCounter--;
+    }
+
+    @Override
+    public void create(boolean bare) throws IOException {}
+
+    @Override
+    public ObjectDatabase getObjectDatabase() {
+      checkIsOpen();
+      return repo.getObjectDatabase();
+    }
+
+    @Override
+    public RefDatabase getRefDatabase() {
+      RefDatabase refDatabase = repo.getRefDatabase();
+      return new RefDatabase() {
+
+        @Override
+        public int hashCode() {
+          return refDatabase.hashCode();
+        }
+
+        @Override
+        public void create() throws IOException {
+          refDatabase.create();
+        }
+
+        @Override
+        public void close() {
+          checkIsOpen();
+          refDatabase.close();
+        }
+
+        @Override
+        public boolean isNameConflicting(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.isNameConflicting(name);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+          return refDatabase.equals(obj);
+        }
+
+        @Override
+        public Collection<String> getConflictingNames(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.getConflictingNames(name);
+        }
+
+        @Override
+        public RefUpdate newUpdate(String name, boolean detach) throws IOException {
+          checkIsOpen();
+          return refDatabase.newUpdate(name, detach);
+        }
+
+        @Override
+        public RefRename newRename(String fromName, String toName) throws IOException {
+          checkIsOpen();
+          return refDatabase.newRename(fromName, toName);
+        }
+
+        @Override
+        public BatchRefUpdate newBatchUpdate() {
+          checkIsOpen();
+          return refDatabase.newBatchUpdate();
+        }
+
+        @Override
+        public boolean performsAtomicTransactions() {
+          checkIsOpen();
+          return refDatabase.performsAtomicTransactions();
+        }
+
+        @Override
+        public Ref exactRef(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.exactRef(name);
+        }
+
+        @Override
+        public String toString() {
+          return refDatabase.toString();
+        }
+
+        @Override
+        public Map<String, Ref> exactRef(String... refs) throws IOException {
+          checkIsOpen();
+          return refDatabase.exactRef(refs);
+        }
+
+        @Override
+        public Ref firstExactRef(String... refs) throws IOException {
+          checkIsOpen();
+          return refDatabase.firstExactRef(refs);
+        }
+
+        @Override
+        public List<Ref> getRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefs();
+        }
+
+        @Override
+        public Map<String, Ref> getRefs(String prefix) throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefs(prefix);
+        }
+
+        @Override
+        public List<Ref> getRefsByPrefix(String prefix) throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefsByPrefix(prefix);
+        }
+
+        @Override
+        public boolean hasRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.hasRefs();
+        }
+
+        @Override
+        public List<Ref> getAdditionalRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.getAdditionalRefs();
+        }
+
+        @Override
+        public Ref peel(Ref ref) throws IOException {
+          checkIsOpen();
+          return refDatabase.peel(ref);
+        }
+
+        @Override
+        public void refresh() {
+          checkIsOpen();
+          refDatabase.refresh();
+        }
+      };
+    }
+
+    @Override
+    public StoredConfig getConfig() {
+      return repo.getConfig();
+    }
+
+    @Override
+    public AttributesNodeProvider createAttributesNodeProvider() {
+      checkIsOpen();
+      return repo.createAttributesNodeProvider();
+    }
+
+    @Override
+    public void scanForRepoChanges() throws IOException {
+      checkIsOpen();
+    }
+
+    @Override
+    public void notifyIndexChanged(boolean internal) {
+      checkIsOpen();
+    }
+
+    @Override
+    public ReflogReader getReflogReader(String refName) throws IOException {
+      checkIsOpen();
+      return repo.getReflogReader(refName);
+    }
+
+    private void checkIsOpen() {
+      if (refCounter <= 0) {
+        throw new IllegalStateException("Repository is not open (refCounter=" + refCounter + ")");
+      }
+    }
+
+    @Override
+    public String getIdentifier() {
+      return "foo";
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6792703..91d5596 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -29,10 +29,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -56,7 +55,7 @@
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   private static final String DEFAULT_REF = "refs/meta/config";
 
   private Project.NameKey project;
@@ -221,13 +220,10 @@
     return u;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
     PersonIdent author =
-        new PersonIdent("J. Author", "author@example.com", Date.from(TimeUtil.now()), TZ);
+        new PersonIdent("J. Author", "author@example.com", TimeUtil.now(), ZONE_ID);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 54407ca..11f3528 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -31,9 +31,8 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -45,7 +44,7 @@
 
 @Ignore
 public class AbstractGroupTest {
-  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
   protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
@@ -60,16 +59,13 @@
   protected Account.Id userId;
   protected PersonIdent userIdent;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void abstractGroupTestSetUp() throws Exception {
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -79,15 +75,12 @@
     allUsersRepo.close();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().toInstant();
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhenAsInstant();
     }
   }
 
@@ -116,11 +109,8 @@
     return md;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index a8f9ff5..8c19732 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -40,9 +40,7 @@
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -64,7 +62,7 @@
   private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
-  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+  private final ZoneId zoneId = ZoneId.of("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
@@ -1044,9 +1042,6 @@
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant committerTimestamp =
@@ -1068,23 +1063,18 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1105,16 +1095,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
@@ -1149,9 +1139,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant committerTimestamp =
         toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1167,24 +1154,19 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
     Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
@@ -1199,16 +1181,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
@@ -1557,13 +1539,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", Date.from(TimeUtil.now()), timeZone);
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index afc56ff..9d8f260 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -44,11 +44,10 @@
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -72,7 +71,7 @@
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
   private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
@@ -558,11 +557,8 @@
     return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 222be83..d7c779e 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -68,8 +68,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.time.Instant;
-import java.util.Date;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -85,7 +84,7 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
@@ -115,15 +114,11 @@
   protected Injector injector;
   private String systemTimeZone;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void setUpTestEnvironment() throws Exception {
     setTimeForTesting();
 
-    serverIdent =
-        new PersonIdent("Gerrit Server", "noreply@gerrit.com", Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..24e28f3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  static class Child {
+    Optional<String> optionalValue;
+  }
+
+  static class Parent {
+    Optional<Child> optionalChild;
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedNonEmptyOptional() {
+    String value = "foo";
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.of(value);
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {\n"
+                + "        \"value\": \"foo\"\n"
+                + "      }\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isPresent();
+    assertThat(result.optionalChild.get().optionalValue.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedEmptyOptional() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {}\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5e2e1f2..9d02067 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -35,9 +35,6 @@
 import org.junit.Test;
 
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
@@ -70,14 +67,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
@@ -145,9 +143,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void submitCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -189,14 +184,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 051ea2d..5e6803e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -49,7 +49,6 @@
 import com.google.inject.Inject;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -321,9 +320,6 @@
     assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
@@ -332,8 +328,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
@@ -374,8 +370,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen().getTime())
-        .isEqualTo(fixedAuthorIdent.getWhen().getTime());
+    assertThat(originalAuthorIdent.getWhenAsInstant())
+        .isEqualTo(fixedAuthorIdent.getWhenAsInstant());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -453,9 +449,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerFooterIdent() throws Exception {
     Change c = newChange();
@@ -502,7 +495,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -539,9 +532,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerMessage() throws Exception {
     Change c = newChange();
@@ -589,7 +579,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -669,9 +659,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixLabelFooterIdent() throws Exception {
     Change c = newChange();
@@ -722,7 +709,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -806,9 +793,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessage() throws Exception {
     Change c = newChange();
@@ -862,7 +846,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -932,18 +916,12 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessageWithUnparsableAuthorIdent() throws Exception {
     Change c = newChange();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
-            changeOwner.getName(),
-            "server@" + serverId,
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            changeOwner.getName(), "server@" + serverId, TimeUtil.now(), serverIdent.getZoneId());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
@@ -1188,9 +1166,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAttentionFooter() throws Exception {
     Change c = newChange();
@@ -1271,7 +1246,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
@@ -1569,9 +1544,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixSubmitChangeMessageAndFooters() throws Exception {
     Change c = newChange();
@@ -1579,8 +1551,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            TimeUtil.now(),
+            serverIdent.getZoneId());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -2281,9 +2253,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
@@ -2293,8 +2262,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
 
     RevCommit invalidUpdateCommit =
         writeUpdate(
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 788703c..1c28690 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -32,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.io.IOException;
-import java.util.Date;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -256,14 +255,11 @@
         : createCommitInRepo(repo, treeId, parentCommit);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommitInRepo(Repository repo, ObjectId treeId, ObjectId... parents)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 21ea641..b0050b0 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -22,9 +22,8 @@
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.Date;
-import java.util.TimeZone;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -95,9 +94,6 @@
     assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -107,20 +103,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       ObjectId commit =
           testRepo
@@ -149,9 +137,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -161,20 +146,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent =
           testRepo.commit().message("Parent subject\n\nParent further details.").create();
@@ -208,9 +185,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -220,20 +194,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
       RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
new file mode 100644
index 0000000..98ee71d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
@@ -0,0 +1,57 @@
+// 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.project;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link SubmitRequirementsUtil#validateName(String)}. */
+@RunWith(JUnit4.class)
+public class SubmitRequirementNameValidatorTest {
+  @Test
+  public void canStartWithSmallLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("abc");
+  }
+
+  @Test
+  public void canStartWithCapitalLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("Abc");
+  }
+
+  @Test
+  public void canBeEqualToOneLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("a");
+  }
+
+  @Test
+  public void cannotStartWithNumber() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("98abc"));
+  }
+
+  @Test
+  public void cannotStartWithHyphen() throws Exception {
+    assertThrows(IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("-abc"));
+  }
+
+  @Test
+  public void cannotContainNonAlphanumericOrHyphen() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("a&^bc"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 4fe4ab04..6d96c10 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -1,8 +1,8 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "small_tests",
-    size = "small",
+    name = "update_tests",
+    size = "medium",
     srcs = glob(["*.java"]),
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
diff --git a/plugins/BUILD b/plugins/BUILD
index 7862b1c..32efa3e 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -64,6 +64,7 @@
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
+    "//java/com/google/gerrit/proto",
     "//java/com/google/gerrit/util/cli",
     "//java/com/google/gerrit/util/http",
     "//java/com/google/gerrit/util/logging",
diff --git a/plugins/replication b/plugins/replication
index fd3b732..bdc7e6b 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit fd3b732959f964763117be5fdff78ee40ed211fa
+Subproject commit bdc7e6b3255965d0e4415a978031222a683b38d8
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index c5c262b..732a82c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -240,34 +240,6 @@
 the "Before launch" section for IntelliJ. This is a temporary problem until
 typescript migration is complete.
 
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
 ### Generated file overview
 
 A generated file starts with imports followed by a static content with
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 6788aa3..430e770 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -38,10 +38,16 @@
  * If the weblinks-only parameter is specified, only the web_links field is set.
  */
 export declare interface DiffInfo {
-  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
-  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -167,7 +173,7 @@
    * Indicates the range (line numbers) on the other side of the comparison
    * where the code related to the current chunk came from/went to.
    */
-  range: {
+  range?: {
     start: number;
     end: number;
   };
@@ -334,6 +340,7 @@
   lineNum: LineNumber;
 }
 
+// TODO: Currently unused and not fired.
 export declare interface RenderProgressEventDetail {
   linesRendered: number;
 }
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 72712ff..685151b 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -130,7 +130,7 @@
 export enum InheritedBooleanInfoConfiguredValue {
   TRUE = 'TRUE',
   FALSE = 'FALSE',
-  INHERITED = 'INHERITED',
+  INHERIT = 'INHERIT',
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 8fa2e90..2c83ed3 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -403,9 +403,9 @@
     permission: PermissionArrayItem<EditablePermissionInfo>
   ): string | undefined {
     if (this.section?.id === GLOBAL_NAME) {
-      return this.capabilities?.[permission.id].name;
+      return this.capabilities?.[permission.id]?.name;
     } else if (AccessPermissions[permission.id]) {
-      return AccessPermissions[permission.id].name;
+      return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
       let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index f4d81c4..1c4c437 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -224,6 +224,12 @@
         element.capabilities![permission.id].name
       );
 
+      permission = {
+        id: 'non-existent' as GitRef,
+        value: {rules: {}},
+      };
+      assert.isUndefined(element.computePermissionName(permission));
+
       element.section.id = 'refs/for/*' as GitRef;
       permission = {
         id: 'abandon' as GitRef,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 9b01735..4ef307d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -272,7 +272,7 @@
         return true;
       case InheritedBooleanInfoConfiguredValue.FALSE:
         return false;
-      case InheritedBooleanInfoConfiguredValue.INHERITED:
+      case InheritedBooleanInfoConfiguredValue.INHERIT:
         return !!config.inherited_value;
       default:
         return false;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 933b300..b4ebd3c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -40,12 +40,9 @@
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
@@ -248,22 +245,6 @@
         .subject:hover .content {
           text-decoration: underline;
         }
-        .u-monospace {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        .u-green,
-        .u-green iron-icon {
-          color: var(--positive-green-text-color);
-        }
-        .u-red,
-        .u-red iron-icon {
-          color: var(--negative-red-text-color);
-        }
-        .u-gray-background {
-          background-color: var(--table-header-background-color);
-        }
         .comma,
         .placeholder {
           color: var(--deemphasized-text-color);
@@ -618,32 +599,13 @@
   }
 
   private renderChangeLabels(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return html` <td class="cell label requirement">
-        <gr-change-list-column-requirement
-          .change=${this.change}
-          .labelName=${labelName}
-        >
-        </gr-change-list-column-requirement>
-      </td>`;
-    }
-    return html`
-      <td
-        title=${this.computeLabelTitle(labelName)}
-        class=${this.computeLabelClass(labelName)}
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
       >
-        ${this.renderChangeHasLabelIcon(labelName)}
-      </td>
-    `;
-  }
-
-  private renderChangeHasLabelIcon(labelName: string) {
-    if (this.computeLabelIcon(labelName) === '')
-      return html`<span>${this.computeLabelValue(labelName)}</span>`;
-
-    return html`
-      <iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
-    `;
+      </gr-change-list-column-requirement>
+    </td>`;
   }
 
   private renderChangePluginEndpoint(pluginEndpointName: string) {
@@ -676,118 +638,6 @@
     return GerritNav.getUrlForChange(this.change);
   }
 
-  // private but used in test
-  computeLabelTitle(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    if (!label || category === LabelCategory.NOT_APPLICABLE) {
-      return 'Label not applicable';
-    }
-    const titleParts: string[] = [];
-    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
-      const num = this.change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
-
-  // private but used in test
-  computeLabelClass(labelName: string) {
-    const classes = ['cell', 'label'];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  // private but used in test
-  computeLabelIcon(labelName: string): string {
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
-    }
-  }
-
-  // private but used in test
-  computeLabelCategory(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
-    }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
-  }
-
-  // private but used in test
-  computeLabelValue(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-      default:
-        return '';
-    }
-  }
-
   private computeRepoUrl() {
     if (!this.change) return '';
     return GerritNav.getUrlForProjectChanges(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index e0ce6a8..7216249 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -48,7 +48,7 @@
 import {StandardLabels} from '../../../utils/label-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -92,209 +92,6 @@
     await element.updateComplete;
   });
 
-  test('computeLabelCategory', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.APPROVED
-    );
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.REJECTED
-    );
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.UNRESOLVED_COMMENTS
-    );
-    element.change.labels = {'Code-Review': {value: 1}};
-    element.change.unresolved_comment_count = 0;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.POSITIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.NEGATIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-  });
-
-  test('computeLabelClass', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-green');
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-red');
-    element.change.labels = {'Code-Review': {value: 1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-green u-monospace'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-monospace u-red'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-  });
-
-  test('computeLabelTitle', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelTitle('Verified'), 'Label not applicable');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Label not applicable'
-    );
-
-    element.change.labels = {Verified: {rejected: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {
-      'Code-Review': {disliked: {name: 'Diffy'}, value: -1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        recommended: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        approved: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, value: 1},
-    };
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment,\nCode-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 2;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '2 unresolved comments'
-    );
-  });
-
-  test('computeLabelIcon', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelIcon('missingLabel'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelIcon('Verified'), 'gr-icons:check');
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(element.computeLabelIcon('Code-Review'), 'gr-icons:comment');
-  });
-
-  test('computeLabelValue', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelValue('Verified'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '+1');
-    element.change.labels = {Verified: {value: -1}};
-    assert.equal(element.computeLabelValue('Verified'), '-1');
-    element.change.labels = {Verified: {approved: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {rejected: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✕');
-  });
-
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
       ColumnNames.SUBJECT,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 24719e8..34080a1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -10,7 +10,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {
-  AccountInfo,
+  AccountDetailInfo,
   ChangeInfo,
   NumericChangeId,
   ServerInfo,
@@ -30,8 +30,14 @@
 import {allSettled} from '../../../utils/async-util';
 import {listForSentence} from '../../../utils/string-util';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
+import {
+  AccountInput,
+  AccountInputDetail,
+} from '../../shared/gr-account-list/gr-account-list';
 import '@polymer/iron-icon/iron-icon';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection} from '../../../utils/common-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
@@ -40,7 +46,7 @@
   // contents are given to gr-account-lists to mutate
   @state() private updatedAccountsByReviewerState: Map<
     ReviewerState,
-    AccountInfo[]
+    AccountInput[]
   > = new Map([
     [ReviewerState.REVIEWER, []],
     [ReviewerState.CC, []],
@@ -72,6 +78,8 @@
 
   private isLoggedIn = false;
 
+  private account?: AccountDetailInfo;
+
   static override get styles() {
     return css`
       gr-dialog {
@@ -125,6 +133,11 @@
       () => getAppContext().userModel.loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
+    subscribe(
+      this,
+      () => getAppContext().userModel.account$,
+      account => (this.account = account)
+    );
   }
 
   override render() {
@@ -244,12 +257,11 @@
       .filter(account => account?._account_id !== undefined);
     return this.updatedAccountsByReviewerState
       .get(updatedReviewerState)!
-      .filter(
-        account =>
-          account._account_id !== undefined &&
-          accountsInCurrentState.some(
-            otherAccount => otherAccount._account_id === account._account_id
-          )
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount =>
+            accountOrGroupKey(otherAccount) === accountOrGroupKey(account)
+        )
       )
       .map(reviewer => getDisplayName(this.serverConfig, reviewer));
   }
@@ -292,7 +304,6 @@
     reviewerState: ReviewerState,
     event: CustomEvent<AccountInputDetail>
   ) {
-    const account = event.detail.account as AccountInfo;
     const oppositeReviewerState =
       reviewerState === ReviewerState.CC
         ? ReviewerState.REVIEWER
@@ -301,7 +312,7 @@
       oppositeReviewerState
     )!;
     const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
-      acc => acc._account_id === account._account_id
+      acc => accountOrGroupKey(acc) === accountOrGroupKey(event.detail.account)
     );
     if (oppositeUpdatedAccountIndex >= 0) {
       oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
@@ -335,7 +346,8 @@
       ])
     );
     const inFlightActions = this.getBulkActionsModel().addReviewers(
-      this.updatedAccountsByReviewerState
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
     );
 
     await allSettled(
@@ -383,13 +395,7 @@
     const reviewersPerChange = this.selectedChanges.map(
       change => change.reviewers[reviewerState] ?? []
     );
-    if (reviewersPerChange.length === 0) {
-      return [];
-    }
-    // Gets reviewers present in all changes
-    return reviewersPerChange.reduce((a, b) =>
-      a.filter(reviewer => b.includes(reviewer))
-    );
+    return intersection(reviewersPerChange);
   }
 
   private createSuggestionsProvider(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index edcad8f..4a142e4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -5,7 +5,7 @@
  */
 import {fixture, html} from '@open-wc/testing-helpers';
 import {SinonStubbedMember} from 'sinon';
-import {AccountInfo, ReviewerState} from '../../../api/rest-api';
+import {AccountInfo, GroupInfo, ReviewerState} from '../../../api/rest-api';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
@@ -17,6 +17,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
 } from '../../../test/test-data-generators';
 import {
   MockPromise,
@@ -43,6 +44,7 @@
   createAccountWithIdNameAndEmail(4),
   createAccountWithIdNameAndEmail(5),
 ];
+const groups: GroupInfo[] = [createGroupInfo('groupId')];
 const changes: ChangeInfo[] = [
   {
     ...createChange(),
@@ -225,7 +227,7 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      reviewerList.accounts.push(accounts[2]);
+      reviewerList.accounts.push(accounts[2], groups[0]);
       ccList.accounts.push(accounts[5]);
       await flush();
       dialog.confirmButton!.click();
@@ -243,8 +245,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
@@ -253,8 +268,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
@@ -357,7 +385,7 @@
       );
       await flush();
 
-      // prettier and shadoDom string don't agree on long text in divs
+      // prettier and shadowDom string don't agree on long text in divs
       expect(element).shadowDom.to.equal(
         /* prettier-ignore */
         /* HTML */ `
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 2a90bfc..2f5965b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -45,7 +45,6 @@
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {queryAll} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
 import {Execution} from '../../../constants/reporting';
 
@@ -309,11 +308,7 @@
         const prefColumns = this.preferences.change_table
           .map(column => (column === 'Project' ? ColumnNames.REPO : column))
           .map(column =>
-            this.flagsService.isEnabled(
-              KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-            ) && column === ColumnNames.STATUS
-              ? ColumnNames.STATUS2
-              : column
+            column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column
           );
         this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, {
           statusColumn: prefColumns.includes(ColumnNames.STATUS2),
@@ -336,50 +331,23 @@
     if (!config || !config.change) return true;
     if (column === 'Comments')
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status') {
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    }
-    if (column === ColumnNames.STATUS2)
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      if (this.config?.submit_requirement_dashboard_columns?.length) {
-        return this.config?.submit_requirement_dashboard_columns;
-      } else {
-        const changes = sections.map(section => section.results).flat();
-        labels = (changes ?? [])
-          .map(change => getRequirements(change))
-          .flat()
-          .map(requirement => requirement.name)
-          .filter(unique);
-      }
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 63a6d8f..1e81123 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -32,6 +32,7 @@
 import {
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
@@ -165,23 +166,36 @@
             {
               ...createChange(),
               _number: 0 as NumericChangeId,
-              labels: {Verified: {approved: {}}},
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 1 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Code-Review': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 2 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Library-Compliance': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
             },
           ],
         },
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 95ad376..9df22f1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -29,7 +29,6 @@
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
@@ -83,11 +82,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -179,8 +174,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
@@ -195,7 +188,6 @@
       :host {
         display: table;
       }
-      gr-change-requirements,
       gr-submit-requirements {
         --requirements-horizontal-padding: var(--metadata-horizontal-padding);
       }
@@ -702,23 +694,13 @@
   }
 
   private renderSubmitRequirements() {
-    if (this.showNewSubmitRequirements()) {
-      return html`<div class="separatedSection">
-        <gr-submit-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-submit-requirements>
-      </div>`;
-    } else {
-      return html` <div class="oldSeparatedSection">
-        <gr-change-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-change-requirements>
-      </div>`;
-    }
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
   }
 
   private renderWeblinks() {
@@ -1213,10 +1195,6 @@
       );
   }
 
-  private showNewSubmitRequirements() {
-    return showNewSubmitRequirements(this.flagsService, this.change);
-  }
-
   private computeVoteForRole(role: ChangeRole) {
     const reviewer = this.getNonOwnerRole(role);
     if (reviewer && isAccount(reviewer)) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 2b48697..a383cb1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -199,8 +199,8 @@
         <span class="title"> Hashtags </span>
         <span class="value"> </span>
       </section>
-      <div class="oldSeparatedSection">
-      <gr-change-requirements></gr-change-requirements>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
       </div>
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param name="labels"> </gr-endpoint-param>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 821e1ce..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {getAppContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels).sort(labelCompare)) {
-      allLabels.push({
-        labelName: label,
-        icon: this._computeLabelIcon(labels[label]),
-        style: this._computeLabelClass(labels[label]),
-        labelInfo: labels[label],
-      });
-    }
-    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
-    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: 'optional labels',
-      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
-    });
-  }
-
-  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
-    return `submit-requirement-item-${item.type}`;
-  }
-
-  _computeShowAllLabelText(_showOptionalLabels: boolean) {
-    if (_showOptionalLabels) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
deleted file mode 100644
index 0005c90..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .show-all-button {
-      float: right;
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-    gr-endpoint-param {
-      display: none;
-    }
-    .metadata-title {
-      font-weight: var(--font-weight-bold);
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .title .metadata-title {
-      padding-left: 0;
-    }
-  </style>
-  <h3 class="metadata-title heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 6ec815c..0d52e79 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -821,30 +821,6 @@
     return html`
       <div>
         <table>
-          <tr ?hidden=${!this.showChecksSummary}>
-            <td class="key">Checks</td>
-            <td class="value">
-              <div class="checksSummary">
-                ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
-                  Category.ERROR
-                )}${this.renderChecksChipForCategory(
-                  Category.WARNING
-                )}${this.renderChecksChipForCategory(
-                  Category.INFO
-                )}${this.renderChecksChipForCategory(
-                  Category.SUCCESS
-                )}${hasNonRunningChip && hasRunningChip
-                  ? html`<br />`
-                  : ''}${this.renderChecksChipRunning()}
-                <span
-                  class="loadingSpin"
-                  ?hidden=${!this.someProvidersAreLoading}
-                ></span>
-                ${this.renderErrorMessages()} ${this.renderChecksLogin()}
-                ${this.renderSummaryMessage()} ${this.renderActions()}
-              </div>
-            </td>
-          </tr>
           <tr>
             <td class="key">Comments</td>
             <td class="value">
@@ -884,6 +860,30 @@
               >
             </td>
           </tr>
+          <tr ?hidden=${!this.showChecksSummary}>
+            <td class="key">Checks</td>
+            <td class="value">
+              <div class="checksSummary">
+                ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
+                  Category.ERROR
+                )}${this.renderChecksChipForCategory(
+                  Category.WARNING
+                )}${this.renderChecksChipForCategory(
+                  Category.INFO
+                )}${this.renderChecksChipForCategory(
+                  Category.SUCCESS
+                )}${hasNonRunningChip && hasRunningChip
+                  ? html`<br />`
+                  : ''}${this.renderChecksChipRunning()}
+                <span
+                  class="loadingSpin"
+                  ?hidden=${!this.someProvidersAreLoading}
+                ></span>
+                ${this.renderErrorMessages()} ${this.renderChecksLogin()}
+                ${this.renderSummaryMessage()} ${this.renderActions()}
+              </div>
+            </td>
+          </tr>
           <tr hidden>
             <td class="key">Findings</td>
             <td class="value"></td>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 7e4cf2c..3db1012 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -633,6 +633,12 @@
 
   private connected$ = new BehaviorSubject(false);
 
+  /**
+   * For `connectedCallback()` to distinguish between connecting to the DOM for
+   * the first time or if just re-connecting.
+   */
+  private isFirstConnection = true;
+
   /** Simply reflects the router-model value. */
   // visible for testing
   routerPatchNum?: PatchSetNum;
@@ -649,12 +655,29 @@
       'fullscreen-overlay-opened',
       () => this._handleHideBackgroundContent()
     );
-
     this.addEventListener('fullscreen-overlay-closed', () =>
       this._handleShowBackgroundContent()
     );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      this.loadData(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
   }
 
   private setupSubscriptions() {
@@ -699,24 +722,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    this.firstConnectedCallback();
     this.connected$.next(true);
-    this.setupSubscriptions();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
+    // Make sure to reverse everything below this line in disconnectedCallback().
+    // Or consider using either firstConnectedCallback() or constructor().
+    this.setupSubscriptions();
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
+  }
+
+  /**
+   * For initialization that should only happen once, not again when
+   * re-connecting to the DOM later.
+   */
+  private firstConnectedCallback() {
+    if (!this.isFirstConnection) return;
+    this.isFirstConnection = false;
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -734,26 +756,21 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('change-message-deleted', () => fireReload(this));
-    this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
-    this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
-    );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    document.addEventListener('visibilitychange', this.handleVisibilityChange);
-    document.addEventListener('scroll', this.handleScroll);
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
 
-    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
-      this._setActivePrimaryTab(e)
-    );
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.restApiService.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
     });
   }
 
@@ -944,6 +961,8 @@
     assertIsDefined(this._change, '_change');
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
+    // to prevent 2 requests at the same time
+    if (this.$.commitMessageEditor.disabled) return;
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 5d4db56..0eac350 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -546,13 +546,13 @@
           diff-prefs="{{_diffPrefs}}"
           change="[[_change]]"
           change-num="[[_changeNum]]"
-          patch-range="{{_patchRange}}"
+          patch-range="[[_patchRange]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
           num-files-shown="{{_numFilesShown}}"
           files-expanded="{{_filesExpanded}}"
-          file-list-increment="{{_numFilesShown}}"
+          file-list-increment="[[_numFilesShown]]"
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
           observer-target="[[_computeObserverTarget()]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 80cf8a4..ce45424 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -103,6 +103,7 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const fixture = fixtureFromElement('gr-change-view');
 
@@ -782,11 +783,10 @@
       assert.isTrue(stub.called);
     });
 
-    test(', should open diff preferences', () => {
-      const stub = sinon.stub(
-        element.$.fileList.$.diffPreferencesDialog,
-        'open'
-      );
+    test(', should open diff preferences', async () => {
+      await element.$.fileList.updateComplete;
+      assertIsDefined(element.$.fileList.diffPreferencesDialog);
+      const stub = sinon.stub(element.$.fileList.diffPreferencesDialog, 'open');
       element._loggedIn = false;
       pressAndReleaseKeyOn(element, 188, null, ',');
       assert.isFalse(stub.called);
@@ -1183,7 +1183,8 @@
     });
   });
 
-  test('diff preferences open when open-diff-prefs is fired', () => {
+  test('diff preferences open when open-diff-prefs is fired', async () => {
+    await element.$.fileList.updateComplete;
     const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
     element.$.fileListHeader.dispatchEvent(
       new CustomEvent('open-diff-prefs', {
@@ -1326,6 +1327,7 @@
       .stub(element, '_reloadPatchNumDependentResources')
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
     flush();
+    await element.$.fileList.updateComplete;
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
     const value: AppElementChangeViewParams = {
       ...createAppElementChangeViewParams(),
@@ -1359,7 +1361,7 @@
     sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
     sinon.stub(element, 'loadAndSetCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
-    flush();
+    await flush();
     const reloadPortedCommentsStub = sinon.stub(
       element.getCommentsModel(),
       'reloadPortedComments'
@@ -1529,7 +1531,7 @@
     );
   });
 
-  test('_handleCommitMessageSave trims trailing whitespace', () => {
+  test('_handleCommitMessageSave trims trailing whitespace', async () => {
     element._change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
     const putStub = stubRestApi('putChangeCommitMessage').returns(
@@ -1541,10 +1543,10 @@
 
     element._handleCommitMessageSave(mockEvent('test \n  test '));
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
+    element.$.commitMessageEditor.disabled = false;
     element._handleCommitMessageSave(mockEvent('  test\ntest'));
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
+    element.$.commitMessageEditor.disabled = false;
     element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 7625756..20d625c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Subscription} from 'rxjs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
@@ -29,14 +28,7 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-file-status-chip/gr-file-status-chip';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-file-list_html';
 import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -62,33 +54,40 @@
   isMagicPath,
   specialFilePathCompare,
 } from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators';
 import {
   BasePatchSetNum,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
-  RevisionPatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {select} from '../../../utils/observable-util';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {fire} from '../../../utils/event-util';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {when} from 'lit/directives/when';
+import {incrementalRepeat} from '../../lit/incremental-repeat';
+import {ifDefined} from 'lit/directives/if-defined';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -101,12 +100,6 @@
 
 const FILE_ROW_CLASS = 'file-row';
 
-export interface GrFileList {
-  $: {
-    diffPreferencesDialog: GrDiffPreferencesDialog;
-  };
-}
-
 interface ReviewedFileInfo extends FileInfo {
   isReviewed?: boolean;
 }
@@ -177,14 +170,26 @@
  * @property {number} lines_inserted - fallback to 0 if not present in api
  */
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(DIPolymerElement);
-
-@customElement('gr-file-list')
-export class GrFileList extends base {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementEventMap {
+    'num-files-shown-changed': ValueChangedEvent<number>;
+    'files-expanded-changed': ValueChangedEvent<FilesExpandedState>;
+    'diff-prefs-changed': ValueChangedEvent<DiffPreferencesInfo>;
   }
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
+@customElement('gr-file-list')
+export class GrFileList extends LitElement {
+  /**
+   * @event selected-index-changed
+   * @event files-expanded-changed
+   * @event num-files-shown-changed
+   * @event diff-prefs-changed
+   */
+  @query('#diffPreferencesDialog')
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
   @property({type: Object})
   patchRange?: PatchRange;
@@ -198,121 +203,94 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Number, notify: true})
+  @property({type: Number, attribute: 'selected-index'})
   selectedIndex = -1;
 
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  @property({type: String})
   diffViewMode?: DiffViewMode;
 
-  @property({type: Boolean, observer: '_editModeChanged'})
+  @property({type: Boolean})
   editMode?: boolean;
 
-  @property({type: String, notify: true})
+  @property({type: String, attribute: 'files-expanded'})
   filesExpanded = FilesExpandedState.NONE;
 
-  @property({type: Object})
-  _filesByPath?: FileNameToFileInfoMap;
+  // Private but used in tests.
+  @state()
+  filesByPath?: FileNameToFileInfoMap;
 
-  @property({type: Array, observer: '_filesChanged'})
-  _files: NormalizedFileInfo[] = [];
+  // Private but used in tests.
+  @state()
+  files: NormalizedFileInfo[] = [];
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
 
   @property({type: Array})
   reviewed?: string[] = [];
 
-  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  @property({type: Object, attribute: 'diff-prefs'})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Number, notify: true})
+  @property({type: Number, attribute: 'num-files-shown'})
   numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Object, computed: '_calculatePatchChange(_files)'})
-  _patchChange: PatchChange = createDefaultPatchChange();
-
-  @property({type: Number})
+  @property({type: Number, attribute: 'file-list-increment'})
   fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
-  _hideChangeTotals = true;
+  // Private but used in tests.
+  shownFiles: NormalizedFileInfo[] = [];
 
-  @property({
-    type: Boolean,
-    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-  })
-  _hideBinaryChangeTotals = true;
+  @state()
+  private reportinShownFilesIncrement = 0;
 
-  @property({
-    type: Array,
-    computed: '_computeFilesShown(numFilesShown, _files)',
-  })
-  _shownFiles: NormalizedFileInfo[] = [];
+  // Private but used in tests.
+  @state()
+  expandedFiles: PatchSetFile[] = [];
 
-  @property({type: Number})
-  _reportinShownFilesIncrement = 0;
+  // Private but used in tests.
+  @state()
+  displayLine?: boolean;
 
-  @property({type: Array})
-  _expandedFiles: PatchSetFile[] = [];
+  @state()
+  loading?: boolean;
 
-  @property({type: Boolean})
-  _displayLine?: boolean;
-
-  @property({type: Boolean, observer: '_loadingChanged'})
-  _loading?: boolean;
-
-  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
-  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
-
-  @property({type: Boolean})
-  _showSizeBars = true;
+  // Private but used in tests.
+  @state()
+  showSizeBars = true;
 
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
   // files that were merged without conflict. These files are also passed to any
   // plugins.
-  @property({type: Array})
-  _cleanlyMergedPaths: string[] = [];
+  @state()
+  private cleanlyMergedPaths: string[] = [];
 
-  @property({type: Array})
-  _cleanlyMergedOldPaths: string[] = [];
+  // Private but used in tests.
+  @state()
+  cleanlyMergedOldPaths: string[] = [];
 
-  private _cancelForEachDiff?: () => void;
+  private cancelForEachDiff?: () => void;
 
   loadingTask?: DelayedTask;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-  })
-  _showDynamicColumns = false;
+  @state()
+  private dynamicHeaderEndpoints?: string[];
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowPrependedDynamicColumns(' +
-      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-  })
-  _showPrependedDynamicColumns = false;
+  @state()
+  private dynamicContentEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
+  @state()
+  private dynamicSummaryEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicContentEndpoints?: string[];
+  @state()
+  private dynamicPrependedHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicSummaryEndpoints?: string[];
-
-  @property({type: Array})
-  _dynamicPrependedHeaderEndpoints?: string[];
-
-  @property({type: Array})
-  _dynamicPrependedContentEndpoints?: string[];
+  @state()
+  private dynamicPrependedContentEndpoints?: string[];
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -326,48 +304,10 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private subscriptions: Subscription[] = [];
-
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
-      listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
-      listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
-      listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
-      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
-        toggleClass(this, 'hideComments')
-      ),
-      listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
-      listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
-      // This is already been taken care of by CURSOR_NEXT_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the next file or line.
-      listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
-      // This is already been taken care of by CURSOR_PREV_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the previous file or line.
-      listen(Shortcut.PREV_LINE, _ => {}), // docOnly
-      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
-      listen(Shortcut.OPEN_LAST_FILE, _ =>
-        this._openSelectedFile(this._files.length - 1)
-      ),
-      listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
-      listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
-      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
-      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
-      listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
-      listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
-      listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
-        this._handleToggleFileReviewed()
-      ),
-      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
-      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
-      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
-    ];
-  }
+  shortcutsController = new ShortcutController(this);
 
   // private but used in test
   fileCursor = new GrCursorManager();
@@ -375,80 +315,506 @@
   // private but used in test
   diffCursor = new GrDiffCursor();
 
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .row {
+          align-items: center;
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+          padding: var(--spacing-xs) var(--spacing-l);
+        }
+        /* The class defines a content visible only to screen readers */
+        .noCommentsScreenReaderText {
+          opacity: 0;
+          max-width: 1px;
+          overflow: hidden;
+          display: none;
+          vertical-align: top;
+        }
+        div[role='gridcell']
+          > div.comments
+          > span:empty
+          + span:empty
+          + span.noCommentsScreenReaderText {
+          /* inline-block instead of block, such that it can control width */
+          display: inline-block;
+        }
+        :host(.loading) .row {
+          opacity: 0.5;
+        }
+        :host(.editMode) .hideOnEdit {
+          display: none;
+        }
+        .showOnEdit {
+          display: none;
+        }
+        :host(.editMode) .showOnEdit {
+          display: initial;
+        }
+        .invisible {
+          visibility: hidden;
+        }
+        .header-row {
+          background-color: var(--background-color-secondary);
+        }
+        .controlRow {
+          align-items: center;
+          display: flex;
+          height: 2.25em;
+          justify-content: center;
+        }
+        .controlRow.invisible,
+        .show-hide.invisible {
+          display: none;
+        }
+        .reviewed,
+        .status {
+          align-items: center;
+          display: inline-flex;
+        }
+        .reviewed {
+          display: inline-block;
+          text-align: left;
+          width: 1.5em;
+        }
+        .file-row {
+          cursor: pointer;
+        }
+        .file-row.expanded {
+          border-bottom: 1px solid var(--border-color);
+          position: -webkit-sticky;
+          position: sticky;
+          top: 0;
+          /* Has to visible above the diff view, and by default has a lower
+            z-index. setting to 1 places it directly above. */
+          z-index: 1;
+        }
+        .file-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        .file-row.selected {
+          background-color: var(--selection-background-color);
+        }
+        .file-row.expanded,
+        .file-row.expanded:hover {
+          background-color: var(--expanded-background-color);
+        }
+        .path {
+          cursor: pointer;
+          flex: 1;
+          /* Wrap it into multiple lines if too long. */
+          white-space: normal;
+          word-break: break-word;
+        }
+        .oldPath {
+          color: var(--deemphasized-text-color);
+        }
+        .header-stats {
+          text-align: center;
+          min-width: 7.5em;
+        }
+        .stats {
+          text-align: right;
+          min-width: 7.5em;
+        }
+        .comments {
+          padding-left: var(--spacing-l);
+          min-width: 7.5em;
+          white-space: nowrap;
+        }
+        .row:not(.header-row) .stats,
+        .total-stats {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          display: flex;
+        }
+        .sizeBars {
+          margin-left: var(--spacing-m);
+          min-width: 7em;
+          text-align: center;
+        }
+        .sizeBars.hide {
+          display: none;
+        }
+        .added,
+        .removed {
+          display: inline-block;
+          min-width: 3.5em;
+        }
+        .added {
+          color: var(--positive-green-text-color);
+        }
+        .removed {
+          color: var(--negative-red-text-color);
+          text-align: left;
+          min-width: 4em;
+          padding-left: var(--spacing-s);
+        }
+        .drafts {
+          color: var(--error-foreground);
+          font-weight: var(--font-weight-bold);
+        }
+        .show-hide-icon:focus {
+          outline: none;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+          width: 1.9em;
+        }
+        .fileListButton {
+          margin: var(--spacing-m);
+        }
+        .totalChanges {
+          justify-content: flex-end;
+          text-align: right;
+        }
+        .warning {
+          color: var(--deemphasized-text-color);
+        }
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+          min-width: 2em;
+        }
+        gr-diff {
+          display: block;
+          overflow-x: auto;
+        }
+        .truncatedFileName {
+          display: none;
+        }
+        .mobile {
+          display: none;
+        }
+        .reviewed {
+          margin-left: var(--spacing-xxl);
+          width: 15em;
+        }
+        .reviewedSwitch {
+          color: var(--link-color);
+          opacity: 0;
+          justify-content: flex-end;
+          width: 100%;
+        }
+        .reviewedSwitch:hover {
+          cursor: pointer;
+          opacity: 100;
+        }
+        .showParentButton {
+          line-height: var(--line-height-normal);
+          margin-bottom: calc(var(--spacing-s) * -1);
+          margin-left: var(--spacing-m);
+          margin-top: calc(var(--spacing-s) * -1);
+        }
+        .row:focus {
+          outline: none;
+        }
+        .row:hover .reviewedSwitch,
+        .row:focus-within .reviewedSwitch,
+        .row.expanded .reviewedSwitch {
+          opacity: 100;
+        }
+        .reviewedLabel {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-l);
+          opacity: 0;
+        }
+        .reviewedLabel.isReviewed {
+          display: initial;
+          opacity: 100;
+        }
+        .editFileControls {
+          width: 7em;
+        }
+        .markReviewed:focus {
+          outline: none;
+        }
+        .markReviewed,
+        .pathLink {
+          display: inline-block;
+          margin: -2px 0;
+          padding: var(--spacing-s) 0;
+          text-decoration: none;
+        }
+        .pathLink:hover span.fullFileName,
+        .pathLink:hover span.truncatedFileName {
+          text-decoration: underline;
+        }
+
+        /** copy on file path **/
+        .pathLink gr-copy-clipboard,
+        .oldPath gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: bottom;
+          --gr-button-padding: 0px;
+        }
+        .row:focus-within gr-copy-clipboard,
+        .row:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+
+        @media screen and (max-width: 1200px) {
+          gr-endpoint-decorator.extra-col {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 1000px) {
+          .reviewed {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 800px) {
+          .desktop {
+            display: none;
+          }
+          .mobile {
+            display: block;
+          }
+          .row.selected {
+            background-color: var(--view-background-color);
+          }
+          .stats {
+            display: none;
+          }
+          .reviewed,
+          .status {
+            justify-content: flex-start;
+          }
+          .comments {
+            min-width: initial;
+          }
+          .expanded .fullFileName,
+          .truncatedFileName {
+            display: inline;
+          }
+          .expanded .truncatedFileName,
+          .fullFileName {
+            display: none;
+          }
+        }
+        :host(.hideComments) {
+          --gr-comment-thread-display: none;
+        }
+      `,
+    ];
+  }
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.fileCursor.cursorTargetClass = 'selected';
     this.fileCursor.focusOnMove = true;
+    this.shortcutsController.addAbstract(Shortcut.LEFT_PANE, _ =>
+      this.handleLeftPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.RIGHT_PANE, _ =>
+      this.handleRightPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_INLINE_DIFF, _ =>
+      this.handleToggleInlineDiff()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ =>
+      this.toggleInlineDiffs()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+      _ => toggleClass(this, 'hideComments')
+    );
+    this.shortcutsController.addAbstract(Shortcut.CURSOR_NEXT_FILE, e =>
+      this.handleCursorNext(e)
+    );
+    this.shortcutsController.addAbstract(Shortcut.CURSOR_PREV_FILE, e =>
+      this.handleCursorPrev(e)
+    );
+    // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the next file or line.
+    this.shortcutsController.addAbstract(Shortcut.NEXT_LINE, _ => {}); // docOnly
+    // This is already been taken care of by CURSOR_PREV_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the previous file or line.
+    this.shortcutsController.addAbstract(Shortcut.PREV_LINE, _ => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.NEW_COMMENT, _ =>
+      this.handleNewComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_LAST_FILE, _ =>
+      this.openSelectedFile(this.files.length - 1)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FIRST_FILE, _ =>
+      this.openSelectedFile(0)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FILE, _ =>
+      this.handleOpenFile()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_CHUNK, _ =>
+      this.handleNextChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_CHUNK, _ =>
+      this.handlePrevChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_COMMENT_THREAD, _ =>
+      this.handleNextComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_COMMENT_THREAD, _ =>
+      this.handlePrevComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+      this.handleToggleFileReviewed()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
+      this.handleToggleLeftPane()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    this.shortcutsController.addAbstract(
+      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      diffView => {
+        this.diffViewMode = diffView;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        this.diffPrefs = diffPreferences;
+        fire(this, 'diff-prefs-changed', {value: this.diffPrefs});
+      }
+    );
+    subscribe(
+      this,
+      () =>
+        select(
+          this.userModel.preferences$,
+          prefs => !!prefs?.size_bar_in_change_table
+        ),
+      sizeBarInChangeTable => {
+        this.showSizeBars = sizeBarInChangeTable;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().reviewedFiles$,
+      reviewedFiles => {
+        this.reviewed = reviewedFiles ?? [];
+      }
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (changedProperties.has('filesByPath')) {
+      this.updateCleanlyMergedPaths();
+    }
+    if (
+      changedProperties.has('diffPrefs') ||
+      changedProperties.has('diffViewMode')
+    ) {
+      this.updateDiffPreferences();
+    }
+    if (
+      changedProperties.has('filesByPath') ||
+      changedProperties.has('changeComments') ||
+      changedProperties.has('patchRange') ||
+      changedProperties.has('reviewed') ||
+      changedProperties.has('loading')
+    ) {
+      changedProperties.set('files', this.files);
+      this.computeFiles();
+    }
+    if (changedProperties.has('loading')) {
+      // Should run after files has been updated.
+      this.loadingChanged();
+    }
+    if (changedProperties.has('files')) {
+      this.filesChanged();
+    }
+    if (
+      changedProperties.has('files') ||
+      changedProperties.has('numFilesShown')
+    ) {
+      this.shownFiles = this.computeFilesShown();
+    }
+    if (changedProperties.has('expandedFiles')) {
+      changedProperties.set('filesExpanded', this.filesExpanded);
+      this.expandedFilesChanged(changedProperties.get('expandedFiles'));
+    }
+    if (changedProperties.has('filesExpanded')) {
+      fire(this, 'files-expanded-changed', {value: this.filesExpanded});
+    }
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.subscriptions = [
-      this.getCommentsModel().changeComments$.subscribe(changeComments => {
-        this.changeComments = changeComments;
-      }),
-      this.getBrowserModel().diffViewMode$.subscribe(
-        diffView => (this.diffViewMode = diffView)
-      ),
-      this.userModel.diffPreferences$.subscribe(diffPreferences => {
-        this.diffPrefs = diffPreferences;
-      }),
-      select(
-        this.userModel.preferences$,
-        prefs => !!prefs?.size_bar_in_change_table
-      ).subscribe(sizeBarInChangeTable => {
-        this._showSizeBars = sizeBarInChangeTable;
-      }),
-      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
-        this.reviewed = reviewedFiles ?? [];
-      }),
-    ];
 
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-view-file-list-header'
         );
-        this._dynamicContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
-            'change-view-file-list-content'
-          );
-        this._dynamicPrependedHeaderEndpoints =
+        this.dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this.dynamicPrependedHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints(
             'change-view-file-list-header-prepend'
           );
-        this._dynamicPrependedContentEndpoints =
+        this.dynamicPrependedContentEndpoints =
           getPluginEndpoints().getDynamicEndpoints(
             'change-view-file-list-content-prepend'
           );
-        this._dynamicSummaryEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
-            'change-view-file-list-summary'
-          );
+        this.dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
 
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicContentEndpoints.length
         ) {
           this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
-          this._dynamicPrependedHeaderEndpoints.length !==
-          this._dynamicPrependedContentEndpoints.length
+          this.dynamicPrependedHeaderEndpoints.length !==
+          this.dynamicPrependedContentEndpoints.length
         ) {
           this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicSummaryEndpoints.length
         ) {
           this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
     this.cleanups.push(
-      addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
+      addGlobalShortcut({key: Key.ESC}, _ => this.handleEscKey()),
       addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
         shouldSuppress: true,
       })
@@ -456,19 +822,589 @@
   }
 
   override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
     this.diffCursor.dispose();
     this.fileCursor.unsetCursor();
-    this._cancelDiffs();
+    this.cancelDiffs();
     this.loadingTask?.cancel();
     for (const cleanup of this.cleanups) cleanup();
     this.cleanups = [];
     super.disconnectedCallback();
   }
 
+  override render() {
+    this.classList.toggle('editMode', this.editMode);
+    const patchChange = this.calculatePatchChange();
+    return html`
+      <h3 class="assistive-tech-only">File list</h3>
+      ${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
+      ${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
+      <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        @reload-diff-preference=${this.handleReloadingDiffPreference}
+      >
+      </gr-diff-preferences-dialog>
+    `;
+  }
+
+  private renderContainer() {
+    return html`
+      <div
+        id="container"
+        @click=${(e: MouseEvent) => this.handleFileListClick(e)}
+        role="grid"
+        aria-label="Files list"
+      >
+        ${this.renderHeaderRow()} ${this.renderShownFiles()}
+        ${when(this.computeShowNumCleanlyMerged(), () =>
+          this.renderCleanlyMerged()
+        )}
+      </div>
+    `;
+  }
+
+  private renderHeaderRow() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    return html` <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedHeaderEndpoints()
+      )}
+
+      <div class="path" role="columnheader">File</div>
+      <div class="comments desktop" role="columnheader">Comments</div>
+      <div class="comments mobile" role="columnheader" title="Comments">C</div>
+      <div class="sizeBars desktop" role="columnheader">Size</div>
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
+      ${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div
+        class="reviewed hideOnEdit"
+        ?hidden=${!this.loggedIn}
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></div>
+    </div>`;
+  }
+
+  private renderPrependedHeaderEndpoints() {
+    return this.dynamicPrependedHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="files" .value=${this.files}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderDynamicHeaderEndpoints() {
+    return this.dynamicHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="extra-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        ></gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderShownFiles() {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const sizeBarLayout = this.computeSizeBarLayout();
+
+    return incrementalRepeat({
+      values: this.shownFiles,
+      mapFn: (f, i) =>
+        this.renderFileRow(
+          f as NormalizedFileInfo,
+          i,
+          sizeBarLayout,
+          showDynamicColumns,
+          showPrependedDynamicColumns
+        ),
+      initialCount: this.fileListIncrement,
+      targetFrameRate: 30,
+    });
+  }
+
+  private renderFileRow(
+    file: NormalizedFileInfo,
+    index: number,
+    sizeBarLayout: SizeBarLayout,
+    showDynamicColumns: boolean,
+    showPrependedDynamicColumns: boolean
+  ) {
+    this.reportRenderedRow(index);
+    const patchSetFile = this.computePatchSetFile(file);
+    return html` <div class="stickyArea">
+      <div
+        class=${`file-row row ${this.computePathClass(file.__path)}`}
+        data-file=${JSON.stringify(patchSetFile)}
+        tabindex="-1"
+        role="row"
+      >
+        <!-- endpoint: change-view-file-list-content-prepend -->
+        ${when(showPrependedDynamicColumns, () =>
+          this.renderPrependedContentEndpointsForFile(file)
+        )}
+        ${this.renderFilePath(file)} ${this.renderFileComments(file)}
+        ${this.renderSizeBar(file, sizeBarLayout)} ${this.renderFileStats(file)}
+        ${when(showDynamicColumns, () =>
+          this.renderDynamicContentEndpointsForFile(file)
+        )}
+        <!-- endpoint: change-view-file-list-content -->
+        ${this.renderReviewed(file)} ${this.renderFileControls(file)}
+        ${this.renderShowHide(file)}
+      </div>
+      ${when(
+        this.isFileExpanded(file.__path),
+        () => html`
+          <gr-diff-host
+            ?noAutoRender=${true}
+            ?showLoadFailure=${true}
+            .displayLine=${this.displayLine}
+            .changeNum=${this.changeNum}
+            .change=${this.change}
+            .patchRange=${this.patchRange}
+            .file=${patchSetFile}
+            .path=${file.__path}
+            .prefs=${this.diffPrefs}
+            .projectName=${this.change?.project}
+            ?noRenderOnPrefsChange=${true}
+          ></gr-diff-host>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderPrependedContentEndpointsForFile(file: NormalizedFileInfo) {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="oldPath" .value=${this.getOldPath(file)}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderFilePath(file: NormalizedFileInfo) {
+    return html` <span class="path" role="gridcell">
+      <a class="pathLink" href=${ifDefined(this.computeDiffURL(file.__path))}>
+        <span title=${computeDisplayPath(file.__path)} class="fullFileName">
+          ${computeDisplayPath(file.__path)}
+        </span>
+        <span
+          title=${computeDisplayPath(file.__path)}
+          class="truncatedFileName"
+        >
+          ${computeTruncatedPath(file.__path)}
+        </span>
+        <gr-file-status-chip .file=${file}></gr-file-status-chip>
+        <gr-copy-clipboard
+          ?hideInput=${true}
+          .text=${file.__path}
+        ></gr-copy-clipboard>
+      </a>
+      ${when(
+        file.old_path,
+        () => html`
+          <div class="oldPath" title=${ifDefined(file.old_path)}>
+            [[file.old_path]]
+            <gr-copy-clipboard
+              ?hideInput=${true}
+              .text=${file.old_path}
+            ></gr-copy-clipboard>
+          </div>
+        `
+      )}
+    </span>`;
+  }
+
+  private renderFileComments(file: NormalizedFileInfo) {
+    return html` <div role="gridcell">
+      <div class="comments desktop">
+        <span class="drafts">${this.computeDraftsString(file)}</span>
+        <span>${this.computeCommentsString(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- Screen readers read the following content only if 2 other
+          spans in the parent div is empty. The content is not visible on
+          the page.
+          Without this span, screen readers don't navigate correctly inside
+          table, because empty div doesn't rendered. For example, VoiceOver
+          jumps back to the whole table.
+          We can use &nbsp instead, but it sounds worse.
+          -->
+          No comments
+        </span>
+      </div>
+      <div class="comments mobile">
+        <span class="drafts">${this.computeDraftsStringMobile(file)}</span>
+        <span>${this.computeCommentsStringMobile(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- The same as for desktop comments -->
+          No comments
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderSizeBar(
+    file: NormalizedFileInfo,
+    sizeBarLayout: SizeBarLayout
+  ) {
+    return html` <div class="desktop" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+          gridcell always visible for screen readers.
+          For example, without a nested div screen readers pronounce the
+          "Commit message" row content with incorrect column headers.
+        -->
+      <div
+        class=${this.computeSizeBarsClass(file.__path)}
+        aria-label="A bar that represents the addition and deletion ratio for the current file"
+      >
+        <svg width="61" height="8">
+          <rect
+            x=${this.computeBarAdditionX(file, sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--positive-green-text-color)"
+            width=${this.computeBarAdditionWidth(file, sizeBarLayout)}
+          ></rect>
+          <rect
+            x=${this.computeBarDeletionX(sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--negative-red-text-color)"
+            width=${this.computeBarDeletionWidth(file, sizeBarLayout)}
+          ></rect>
+        </svg>
+      </div>
+    </div>`;
+  }
+
+  private renderFileStats(file: NormalizedFileInfo) {
+    return html` <div class="stats" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+        gridcell always visible for screen readers.
+        For example, without a nested div screen readers pronounce the
+        "Commit message" row content with incorrect column headers.
+        -->
+      <div class=${this.computeClass('', file.__path)}>
+        <span
+          class="added"
+          tabindex="0"
+          aria-label=${`${file.lines_inserted} lines added`}
+          ?hidden=${file.binary}
+        >
+          +${file.lines_inserted}
+        </span>
+        <span
+          class="removed"
+          tabindex="0"
+          aria-label=${`${file.lines_deleted} lines removed`}
+          ?hidden=${file.binary}
+        >
+          -${file.lines_deleted}
+        </span>
+        <span
+          class=${ifDefined(this.computeBinaryClass(file.size_delta))}
+          ?hidden=${!file.binary}
+        >
+          ${this.formatBytes(file.size_delta)}
+          ${this.formatPercentage(file.size, file.size_delta)}
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderDynamicContentEndpointsForFile(file: NormalizedFileInfo) {
+    this.dynamicContentEndpoints?.map(
+      contentEndpoint => html` <div
+        class=${this.computeClass('', file.__path)}
+        role="gridcell"
+      >
+        <gr-endpoint-decorator class="extra-col" .name=${contentEndpoint}>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+  }
+
+  private renderReviewed(file: NormalizedFileInfo) {
+    if (!this.loggedIn) return nothing;
+    return html` <div class="reviewed hideOnEdit" role="gridcell">
+      <span
+        class=${`reviewedLabel ${this.computeReviewedClass(file.isReviewed)}`}
+        aria-hidden=${this.booleanToString(!file.isReviewed)}
+        >Reviewed</span
+      >
+      <!-- Do not use input type="checkbox" with hidden input and
+              visible label here. Screen readers don't read/interract
+              correctly with such input.
+          -->
+      <span
+        class="reviewedSwitch"
+        role="switch"
+        tabindex="0"
+        @click=${(e: MouseEvent) => this.reviewedClick(e)}
+        @keydown=${(e: KeyboardEvent) => this.reviewedClick(e)}
+        aria-label="Reviewed"
+        aria-checked=${this.booleanToString(file.isReviewed)}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+            preserve focus outline for keyboard navigation -->
+        <span
+          tabindex="-1"
+          class="markReviewed"
+          title=${this.reviewedTitle(file.isReviewed)}
+          >${this.computeReviewedText(file.isReviewed)}</span
+        >
+      </span>
+    </div>`;
+  }
+
+  private renderFileControls(file: NormalizedFileInfo) {
+    return html` <div
+      class="editFileControls showOnEdit"
+      role="gridcell"
+      aria-hidden=${this.booleanToString(!this.editMode)}
+    >
+      ${when(
+        this.editMode,
+        () => html`
+          <gr-edit-file-controls
+            class=${this.computeClass('', file.__path)}
+            .filePath=${file.__path}
+          ></gr-edit-file-controls>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderShowHide(file: NormalizedFileInfo) {
+    return html` <div class="show-hide" role="gridcell">
+      <!-- Do not use input type="checkbox" with hidden input and
+            visible label here. Screen readers don't read/interract
+            correctly with such input.
+        -->
+      <span
+        class="show-hide"
+        data-path=${file.__path}
+        data-expand="true"
+        role="switch"
+        tabindex="0"
+        aria-checked=${this.isFileExpandedStr(file.__path)}
+        aria-label="Expand file"
+        @click=${this.expandedClick}
+        @keydown=${this.expandedClick}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+          preserve focus outline for keyboard navigation -->
+        <iron-icon
+          class="show-hide-icon"
+          tabindex="-1"
+          id="icon"
+          icon=${this.computeShowHideIcon(file.__path)}
+        >
+        </iron-icon>
+      </span>
+    </div>`;
+  }
+
+  private renderCleanlyMerged() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    return html` <div class="row">
+      <!-- endpoint: change-view-file-list-content-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedContentEndpoints()
+      )}
+      <div role="gridcell">
+        <div>
+          <span class="cleanlyMergedText">
+            ${this.computeCleanlyMergedText()}
+          </span>
+          <gr-button
+            link
+            class="showParentButton"
+            @click=${this.handleShowParent1}
+          >
+            Show Parent 1
+          </gr-button>
+        </div>
+      </div>
+    </div>`;
+  }
+
+  private renderPrependedContentEndpoints() {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedPaths"
+            .value=${this.cleanlyMergedPaths}
+          >
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedOldPaths"
+            .value=${this.cleanlyMergedOldPaths}
+          >
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderChangeTotals(patchChange: PatchChange) {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    if (this.shouldHideChangeTotals(patchChange)) return nothing;
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <div>
+            <span
+              class="added"
+              tabindex="0"
+              aria-label="Total ${patchChange.inserted} lines added"
+            >
+              +${patchChange.inserted}
+            </span>
+            <span
+              class="removed"
+              tabindex="0"
+              aria-label="Total ${patchChange.deleted} lines removed"
+            >
+              -${patchChange.deleted}
+            </span>
+          </div>
+        </div>
+        ${when(showDynamicColumns, () =>
+          this.dynamicSummaryEndpoints?.map(
+            summaryEndpoint => html`
+              <gr-endpoint-decorator class="extra-col" name=${summaryEndpoint}>
+                <gr-endpoint-param name="change" .value=${this.change}>
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            `
+          )
+        )}
+
+        <!-- Empty div here exists to keep spacing in sync with file rows. -->
+        <div class="reviewed hideOnEdit" ?hidden=${!this.loggedIn}></div>
+        <div class="editFileControls showOnEdit"></div>
+        <div class="show-hide"></div>
+      </div>
+    `;
+  }
+
+  private renderBinaryTotals(patchChange: PatchChange) {
+    if (this.shouldHideBinaryChangeTotals(patchChange)) return nothing;
+    const deltaInserted = this.formatBytes(patchChange.size_delta_inserted);
+    const deltaDeleted = this.formatBytes(patchChange.size_delta_deleted);
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <span
+            class="added"
+            aria-label="Total bytes inserted: ${deltaInserted}"
+          >
+            ${deltaInserted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_inserted
+            )}
+          </span>
+          <span
+            class="removed"
+            aria-label="Total bytes removed: ${deltaDeleted}"
+          >
+            ${deltaDeleted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_deleted
+            )}
+          </span>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderControlRow() {
+    return html`<div
+      class=${`row controlRow ${this.computeFileListControlClass()}`}
+    >
+      <gr-button
+        class="fileListButton"
+        id="incrementButton"
+        link=""
+        @click=${this.incrementNumFilesShown}
+      >
+        ${this.computeIncrementText()}
+      </gr-button>
+      <gr-tooltip-content
+        ?has-tooltip=${this.computeWarnShowAll()}
+        ?show-icon=${this.computeWarnShowAll()}
+        .title=${this.computeShowAllWarning()}
+      >
+        <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          link=""
+          @click=${this.showAllFiles}
+        >
+          ${this.computeShowAllText()}
+        </gr-button>
+      </gr-tooltip-content>
+    </div>`;
+  }
+
   reload() {
     if (!this.changeNum || !this.patchRange?.patchNum) {
       return Promise.resolve();
@@ -476,7 +1412,7 @@
     const changeNum = this.changeNum;
     const patchRange = this.patchRange;
 
-    this._loading = true;
+    this.loading = true;
 
     this.collapseAllDiffs();
     const promises: Promise<boolean | void>[] = [];
@@ -485,23 +1421,22 @@
       this.restApiService
         .getChangeOrEditFiles(changeNum, patchRange)
         .then(filesByPath => {
-          this._filesByPath = filesByPath;
+          this.filesByPath = filesByPath;
         })
     );
 
     promises.push(
-      this._getLoggedIn().then(loggedIn => (this._loggedIn = loggedIn))
+      this.getLoggedIn().then(loggedIn => (this.loggedIn = loggedIn))
     );
 
     return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
+      this.loading = false;
+      this.detectChromiteButler();
       this.reporting.fileListDisplayed();
     });
   }
 
-  @observe('_filesByPath')
-  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
+  private async updateCleanlyMergedPaths() {
     // When viewing Auto Merge base vs a patchset, add an additional row that
     // knows how many files were cleanly merged. This requires an additional RPC
     // for the diffs between target parent and the patch set. The cleanly merged
@@ -522,21 +1457,21 @@
           patchNum: this.patchRange.patchNum,
         }
       );
-      if (!allFilesByPath || !filesByPath) return;
-      const conflictingPaths = Object.keys(filesByPath);
-      this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+      if (!allFilesByPath || !this.filesByPath) return;
+      const conflictingPaths = Object.keys(this.filesByPath);
+      this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
         path => !conflictingPaths.includes(path)
       );
-      this._cleanlyMergedOldPaths = this._cleanlyMergedPaths
+      this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
         .map(path => allFilesByPath[path].old_path)
         .filter((oldPath): oldPath is string => !!oldPath);
     } else {
-      this._cleanlyMergedPaths = [];
-      this._cleanlyMergedOldPaths = [];
+      this.cleanlyMergedPaths = [];
+      this.cleanlyMergedOldPaths = [];
     }
   }
 
-  _detectChromiteButler() {
+  private detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
       this.reporting.reportExtension('butler');
@@ -544,7 +1479,7 @@
   }
 
   get diffs(): GrDiffHost[] {
-    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    const diffs = this.shadowRoot!.querySelectorAll('gr-diff-host');
     // It is possible that a bogus diff element is hanging around invisibly
     // from earlier with a different patch set choice and associated with a
     // different entry in the files array. So filter on visible items only.
@@ -554,12 +1489,13 @@
   }
 
   openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
+    this.diffPreferencesDialog?.open();
   }
 
-  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
-    const magicFilesExcluded = files.filter(
-      files => !isMagicPath(files.__path)
+  // Private but used in tests.
+  calculatePatchChange(): PatchChange {
+    const magicFilesExcluded = this.files.filter(
+      file => !isMagicPath(file.__path)
     );
 
     return magicFilesExcluded.reduce((acc, obj) => {
@@ -582,40 +1518,39 @@
   }
 
   // private but used in test
-  _toggleFileExpanded(file: PatchSetFile) {
+  toggleFileExpanded(file: PatchSetFile) {
     // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const indexInExpanded = this._expandedFiles.findIndex(
+    const indexInExpanded = this.expandedFiles.findIndex(
       f => f.path === file.path
     );
     if (indexInExpanded === -1) {
-      this.push('_expandedFiles', file);
+      this.expandedFiles = this.expandedFiles.concat([file]);
     } else {
-      this.splice('_expandedFiles', indexInExpanded, 1);
+      this.expandedFiles = this.expandedFiles.filter(
+        (_val, idx) => idx !== indexInExpanded
+      );
     }
-    const indexInAll = this._files.findIndex(f => f.__path === file.path);
-    this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
+    const indexInAll = this.files.findIndex(f => f.__path === file.path);
+    this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
       indexInAll
     ].scrollIntoView({block: 'nearest'});
   }
 
-  _toggleFileExpandedByIndex(index: number) {
-    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  private toggleFileExpandedByIndex(index: number) {
+    this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
   }
 
-  _updateDiffPreferences() {
+  // Private but used in tests.
+  updateDiffPreferences() {
     if (!this.diffs.length) {
       return;
     }
     // Re-render all expanded diffs sequentially.
-    this._renderInOrder(
-      this._expandedFiles,
-      this.diffs,
-      this._expandedFiles.length
-    );
+    this.renderInOrder(this.expandedFiles, this.diffs);
   }
 
-  _forEachDiff(fn: (host: GrDiffHost) => void) {
+  private forEachDiff(fn: (host: GrDiffHost) => void) {
     const diffs = this.diffs;
     for (let i = 0; i < diffs.length; i++) {
       fn(diffs[i]);
@@ -627,54 +1562,45 @@
     // expanded list.
     const newFiles: PatchSetFile[] = [];
     let path: string;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+    for (let i = 0; i < this.shownFiles.length; i++) {
+      path = this.shownFiles[i].__path;
+      if (!this.expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
       }
     }
 
-    this.splice('_expandedFiles', 0, 0, ...newFiles);
+    this.expandedFiles = newFiles.concat(this.expandedFiles);
   }
 
   collapseAllDiffs() {
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-    this.diffCursor.handleDiffUpdate();
+    this.expandedFiles = [];
   }
 
   /**
    * Computes a string with the number of comments and unresolved comments.
    */
-  _computeCommentsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsString(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file?.__path === undefined
     ) {
       return '';
     }
-    return changeComments.computeCommentsString(patchRange, file.__path, file);
+    return this.changeComments.computeCommentsString(
+      this.patchRange,
+      file.__path,
+      file
+    );
   }
 
   /**
    * Computes a string with the number of drafts.
    */
-  _computeDraftsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsString(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     if (draftCount === 0) return '';
@@ -683,15 +1609,12 @@
 
   /**
    * Computes a shortened string with the number of drafts.
+   * Private but used in tests.
    */
-  _computeDraftsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsStringMobile(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     return draftCount === 0 ? '' : `${draftCount}d`;
@@ -700,43 +1623,38 @@
   /**
    * Computes a shortened string with the number of comments.
    */
-  _computeCommentsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsStringMobile(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file === undefined
     ) {
       return '';
     }
     const commentThreadCount =
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.basePatchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.basePatchNum,
         path: file.__path,
       }) +
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.patchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.patchNum,
         path: file.__path,
       });
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
-  // private but used in test
-  _reviewFile(path: string, reviewed?: boolean) {
+  // Private but used in tests.
+  reviewFile(path: string, reviewed?: boolean) {
     if (this.editMode) {
       return Promise.resolve();
     }
-    const index = this._files.findIndex(file => file.__path === path);
-    reviewed = reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    const index = this.files.findIndex(file => file.__path === path);
+    reviewed = reviewed || !this.files[index].isReviewed;
+    this.files[index].isReviewed = reviewed;
+    if (index < this.shownFiles.length) {
+      this.requestUpdate('shownFiles');
     }
-
+    this.requestUpdate('files');
     return this._saveReviewedState(path, reviewed);
   }
 
@@ -753,18 +1671,11 @@
     );
   }
 
-  _getLoggedIn() {
+  private getLoggedIn() {
     return this.restApiService.getLoggedIn();
   }
 
-  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
-    if (this.editMode) {
-      return Promise.resolve([]);
-    }
-    return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum);
-  }
-
-  _normalizeChangeFilesResponse(
+  private normalizeChangeFilesResponse(
     response: FileNameToReviewedFileInfoMap
   ): NormalizedFileInfo[] {
     const paths = Object.keys(response).sort(specialFilePathCompare);
@@ -786,7 +1697,7 @@
    * The click is: mouse click or pressing Enter or Space key
    * P.S> Screen readers sends click event as well
    */
-  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+  private isClickEvent(e: MouseEvent | KeyboardEvent) {
     if (e.type === 'click') {
       return true;
     }
@@ -795,41 +1706,43 @@
     return ke.type === 'keydown' && isSpaceOrEnter;
   }
 
-  _fileActionClick(
+  private fileActionClick(
     e: MouseEvent | KeyboardEvent,
     fileAction: (file: PatchSetFile) => void
   ) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
+    if (this.isClickEvent(e)) {
+      const fileRow = this.getFileRowFromEvent(e);
       if (!fileRow) {
         return;
       }
       // Prevent default actions (e.g. scrolling for space key)
       e.preventDefault();
-      // Prevent _handleFileListClick handler call
+      // Prevent handleFileListClick handler call
       e.stopPropagation();
       this.fileCursor.setCursor(fileRow.element);
       fileAction(fileRow.file);
     }
   }
 
-  _reviewedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._reviewFile(file.path));
+  // Private but used in tests.
+  reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.reviewFile(file.path));
   }
 
-  _expandedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  private expandedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.toggleFileExpanded(file));
   }
 
   /**
    * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
+   * Private but used in tests.
    */
-  _handleFileListClick(e: MouseEvent) {
+  handleFileListClick(e: MouseEvent) {
     if (!e.target) {
       return;
     }
-    const fileRow = this._getFileRowFromEvent(e);
+    const fileRow = this.getFileRowFromEvent(e);
     if (!fileRow) {
       return;
     }
@@ -849,10 +1762,10 @@
 
     e.preventDefault();
     this.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
+    this.toggleFileExpanded(file);
   }
 
-  _getFileRowFromEvent(e: Event): FileRow | null {
+  private getFileRowFromEvent(e: Event): FileRow | null {
     // Traverse upwards to find the row element if the target is not the row.
     let row = e.target as HTMLElement;
     while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
@@ -873,7 +1786,7 @@
   /**
    * Generates file range from file info object.
    */
-  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+  private computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
     const fileData: PatchSetFile = {
       path: file.__path,
     };
@@ -883,90 +1796,95 @@
     return fileData;
   }
 
-  _handleLeftPane() {
-    if (this._noDiffsExpanded()) return;
+  private handleLeftPane() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveLeft();
   }
 
-  _handleRightPane() {
-    if (this._noDiffsExpanded()) return;
+  private handleRightPane() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveRight();
   }
 
-  _handleToggleInlineDiff() {
+  private handleToggleInlineDiff() {
     if (this.fileCursor.index === -1) return;
-    this._toggleFileExpandedByIndex(this.fileCursor.index);
+    this.toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
-  _handleCursorNext(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorNext(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor.moveDown();
-      this._displayLine = true;
+      this.displayLine = true;
     } else {
       if (e.key === Key.DOWN) return;
       this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
+      fire(this, 'selected-index-changed', {value: this.fileCursor.index});
     }
   }
 
-  _handleCursorPrev(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorPrev(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor.moveUp();
-      this._displayLine = true;
+      this.displayLine = true;
     } else {
       if (e.key === Key.UP) return;
       this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
+      fire(this, 'selected-index-changed', {value: this.fileCursor.index});
     }
   }
 
-  _handleNewComment() {
+  private handleNewComment() {
     this.classList.remove('hideComments');
     this.diffCursor.createCommentInPlace();
   }
 
+  // Private but used in tests.
   handleOpenFile() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this._openCursorFile();
+      this.openCursorFile();
       return;
     }
-    this._openSelectedFile();
+    this.openSelectedFile();
   }
 
-  _handleNextChunk() {
-    if (this._noDiffsExpanded()) return;
+  private handleNextChunk() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveToNextChunk();
   }
 
-  _handleNextComment() {
-    if (this._noDiffsExpanded()) return;
+  private handleNextComment() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveToNextCommentThread();
   }
 
-  _handlePrevChunk() {
-    if (this._noDiffsExpanded()) return;
+  private handlePrevChunk() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveToPreviousChunk();
   }
 
-  _handlePrevComment() {
-    if (this._noDiffsExpanded()) return;
+  private handlePrevComment() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor.moveToPreviousCommentThread();
   }
 
-  _handleToggleFileReviewed() {
-    if (!this._files[this.fileCursor.index]) {
+  private handleToggleFileReviewed() {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
-    this._reviewFile(this._files[this.fileCursor.index].__path);
+    this.reviewFile(this.files[this.fileCursor.index].__path);
   }
 
-  _handleToggleLeftPane() {
-    this._forEachDiff(diff => {
+  private handleToggleLeftPane() {
+    this.forEachDiff(diff => {
       diff.toggleLeftDiff();
     });
   }
 
-  _toggleInlineDiffs() {
+  private toggleInlineDiffs() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.collapseAllDiffs();
     } else {
@@ -974,7 +1892,8 @@
     }
   }
 
-  _openCursorFile() {
+  // Private but used in tests.
+  openCursorFile() {
     const diff = this.diffCursor.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
@@ -987,11 +1906,12 @@
     );
   }
 
-  _openSelectedFile(index?: number) {
+  // Private but used in tests.
+  openSelectedFile(index?: number) {
     if (index !== undefined) {
       this.fileCursor.setCursorAtIndex(index);
     }
-    if (!this._files[this.fileCursor.index]) {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
     if (!this.change || !this.patchRange) {
@@ -999,45 +1919,52 @@
     }
     GerritNav.navigateToDiff(
       this.change,
-      this._files[this.fileCursor.index].__path,
+      this.files[this.fileCursor.index].__path,
       this.patchRange.patchNum,
       this.patchRange.basePatchNum
     );
   }
 
-  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  // Private but used in tests.
+  shouldHideChangeTotals(patchChange: PatchChange): boolean {
+    return patchChange.inserted === 0 && patchChange.deleted === 0;
   }
 
-  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+  // Private but used in tests.
+  shouldHideBinaryChangeTotals(patchChange: PatchChange) {
     return (
-      _patchChange.size_delta_inserted === 0 &&
-      _patchChange.size_delta_deleted === 0
+      patchChange.size_delta_inserted === 0 &&
+      patchChange.size_delta_deleted === 0
     );
   }
 
-  _computeDiffURL(
-    change?: ParsedChangeInfo,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: RevisionPatchSetNum,
-    path?: string,
-    editMode?: boolean
-  ) {
+  // Private but used in tests
+  computeDiffURL(path?: string) {
     if (
-      change === undefined ||
-      patchNum === undefined ||
+      this.change === undefined ||
+      this.patchRange?.patchNum === undefined ||
       path === undefined ||
-      editMode === undefined
+      this.editMode === undefined
     ) {
       return;
     }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchNum);
+    if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return GerritNav.getEditUrlForDiff(
+        this.change,
+        path,
+        this.patchRange.patchNum
+      );
     }
-    return GerritNav.getUrlForDiff(change, path, patchNum, basePatchNum);
+    return GerritNav.getUrlForDiff(
+      this.change,
+      path,
+      this.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
   }
 
-  _formatBytes(bytes?: number) {
+  // Private but used in tests.
+  formatBytes(bytes?: number) {
     if (!bytes) return '+/-0 B';
     const bits = 1024;
     const decimals = 1;
@@ -1050,7 +1977,8 @@
     return `${prepend}${value} ${sizes[exponent]}`;
   }
 
-  _formatPercentage(size?: number, delta?: number) {
+  // Private but used in tests.
+  formatPercentage(size?: number, delta?: number) {
     if (size === undefined || delta === undefined) {
       return '';
     }
@@ -1064,14 +1992,14 @@
     return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
   }
 
-  _computeBinaryClass(delta?: number) {
+  private computeBinaryClass(delta?: number) {
     if (!delta) {
       return;
     }
     return delta > 0 ? 'added' : 'removed';
   }
 
-  _computeClass(baseClass?: string, path?: string) {
+  private computeClass(baseClass?: string, path?: string) {
     const classes = [];
     if (baseClass) {
       classes.push(baseClass);
@@ -1085,32 +2013,26 @@
     return classes.join(' ');
   }
 
-  _computePathClass(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  private computePathClass(path: string | undefined) {
+    return this.isFileExpanded(path) ? 'expanded' : '';
   }
 
-  _computeShowHideIcon(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord)
+  private computeShowHideIcon(path: string | undefined) {
+    return this.isFileExpanded(path)
       ? 'gr-icons:expand-less'
       : 'gr-icons:expand-more';
   }
 
-  _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean {
-    return cleanlyMergedPaths.length > 0;
+  private computeShowNumCleanlyMerged(): boolean {
+    return this.cleanlyMergedPaths.length > 0;
   }
 
-  _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string {
-    const fileCount = pluralize(cleanlyMergedPaths.length, 'file');
+  private computeCleanlyMergedText(): string {
+    const fileCount = pluralize(this.cleanlyMergedPaths.length, 'file');
     return `${fileCount} merged cleanly in Parent 1`;
   }
 
-  _handleShowParent1(): void {
+  private handleShowParent1(): void {
     if (!this.change || !this.patchRange) return;
     GerritNav.navigateToChange(this.change, {
       patchNum: this.patchRange.patchNum,
@@ -1118,56 +2040,34 @@
     });
   }
 
-  @observe(
-    '_filesByPath',
-    'changeComments',
-    'patchRange',
-    'reviewed',
-    '_loading'
-  )
-  _computeFiles(
-    filesByPath?: FileNameToFileInfoMap,
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    reviewed?: string[],
-    loading?: boolean
-  ) {
-    // Polymer 2: check for undefined
+  private computeFiles() {
     if (
-      filesByPath === undefined ||
-      changeComments === undefined ||
-      patchRange === undefined ||
-      reviewed === undefined ||
-      loading === undefined
+      this.filesByPath === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
+      this.reviewed === undefined ||
+      this.loading === undefined
     ) {
       return;
     }
     // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) {
+    if (this.loading || !this.changeComments) {
       return;
     }
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+    const commentedPaths = this.changeComments.getPaths(this.patchRange);
+    const files: FileNameToReviewedFileInfoMap = {...this.filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
+    const reviewedSet = new Set(this.reviewed || []);
     for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
       reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
     }
-    this._files = this._normalizeChangeFilesResponse(files);
+    this.files = this.normalizeChangeFilesResponse(files);
   }
 
-  _computeFilesShown(
-    numFilesShown: number,
-    files: NormalizedFileInfo[]
-  ): NormalizedFileInfo[] | undefined {
-    // Polymer 2: check for undefined
-    if (numFilesShown === undefined || files === undefined) return undefined;
+  private computeFilesShown(): NormalizedFileInfo[] {
+    const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
-    const previousNumFilesShown = this._shownFiles
-      ? this._shownFiles.length
-      : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
+    const filesShown = this.files.slice(0, this.numFilesShown);
     this.dispatchEvent(
       new CustomEvent('files-shown-changed', {
         detail: {length: filesShown.length},
@@ -1177,12 +2077,12 @@
     );
 
     // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
+    // shownFiles property is being set, and shownFiles is used in the
     // dom-repeat binding.
     this.reporting.time(Timing.FILE_RENDER);
 
     // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement = Math.max(
+    this.reportinShownFilesIncrement = Math.max(
       0,
       filesShown.length - previousNumFilesShown
     );
@@ -1190,59 +2090,56 @@
     return filesShown;
   }
 
-  _updateDiffCursor() {
+  // Private but used in tests.
+  updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
     this.diffCursor.replaceDiffs(this.diffs);
   }
 
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.fileCursor.stops = Array.from(
-        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
-      );
-      this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
+  async filesChanged() {
+    if (!this.files || this.files.length === 0) return;
+    await this.updateComplete;
+    this.fileCursor.stops = Array.from(
+      this.shadowRoot?.querySelectorAll(`.${FILE_ROW_CLASS}`) ?? []
+    );
+    this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
   }
 
-  _incrementNumFilesShown() {
+  private incrementNumFilesShown() {
     this.numFilesShown += this.fileListIncrement;
+    fire(this, 'num-files-shown-changed', {value: this.numFilesShown});
   }
 
-  _computeFileListControlClass(
-    numFilesShown?: number,
-    files?: NormalizedFileInfo[]
-  ) {
-    if (numFilesShown === undefined || files === undefined) return 'invisible';
-    return numFilesShown >= files.length ? 'invisible' : '';
+  private computeFileListControlClass() {
+    return this.numFilesShown >= this.files.length ? 'invisible' : '';
   }
 
-  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
-    if (numFilesShown === undefined || files === undefined) return '';
-    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+  private computeIncrementText() {
+    const text = Math.min(
+      this.fileListIncrement,
+      this.files.length - this.numFilesShown
+    );
     return `Show ${text} more`;
   }
 
-  _computeShowAllText(files: NormalizedFileInfo[]) {
-    if (!files) {
+  private computeShowAllText() {
+    return `Show all ${this.files.length} files`;
+  }
+
+  private computeWarnShowAll() {
+    return this.files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  private computeShowAllWarning() {
+    if (!this.computeWarnShowAll()) {
       return '';
     }
-    return `Show all ${files.length} files`;
+    return `Warning: showing all ${this.files.length} files may take several seconds.`;
   }
 
-  _computeWarnShowAll(files: NormalizedFileInfo[]) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files: NormalizedFileInfo[]) {
-    if (!this._computeWarnShowAll(files)) {
-      return '';
-    }
-    return `Warning: showing all ${files.length} files may take several seconds.`;
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
+  private showAllFiles() {
+    this.numFilesShown = this.files.length;
+    fire(this, 'num-files-shown-changed', {value: this.numFilesShown});
   }
 
   /**
@@ -1254,33 +2151,22 @@
    *
    * @return 'true' if val is true-like, otherwise false
    */
-  _booleanToString(val?: unknown) {
+  private booleanToString(val?: unknown) {
     return val ? 'true' : 'false';
   }
 
-  _isFileExpanded(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return expandedFilesRecord.base.some(f => f.path === path);
+  private isFileExpanded(path: string | undefined) {
+    return this.expandedFiles.some(f => f.path === path);
   }
 
-  _isFileExpandedStr(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._booleanToString(
-      this._isFileExpanded(path, expandedFilesRecord)
-    );
+  private isFileExpandedStr(path: string | undefined) {
+    return this.booleanToString(this.isFileExpanded(path));
   }
 
-  private _computeExpandedFiles(
-    expandedCount: number,
-    totalCount: number
-  ): FilesExpandedState {
-    if (expandedCount === 0) {
+  private computeExpandedFiles(): FilesExpandedState {
+    if (this.expandedFiles.length === 0) {
       return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
+    } else if (this.expandedFiles.length === this.files.length) {
       return FilesExpandedState.ALL;
     }
     return FilesExpandedState.SOME;
@@ -1292,44 +2178,35 @@
    * order by waiting for the previous diff to finish before starting the next
    * one.
    *
-   * @param record The splice record in the expanded paths list.
+   * @param newFiles The new files that have been added.
+   * Private but used in tests.
    */
-  @observe('_expandedFiles.splices')
-  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+  async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
     // Clear content for any diffs that are not open so if they get re-opened
     // the stale content does not flash before it is cleared and reloaded.
     const collapsedDiffs = this.diffs.filter(
-      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+      diff => this.expandedFiles.findIndex(f => f.path === diff.path) === -1
     );
-    this._clearCollapsedDiffs(collapsedDiffs);
+    this.clearCollapsedDiffs(collapsedDiffs);
 
-    if (!record) {
-      return;
-    } // Happens after "Collapse all" clicked.
+    this.filesExpanded = this.computeExpandedFiles();
 
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices.flatMap(splice =>
-      splice.object.slice(splice.index, splice.index + splice.addedCount)
+    const newFiles = this.expandedFiles.filter(
+      file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
     );
 
     // Required so that the newly created diff view is included in this.diffs.
-    flush();
+    await this.updateComplete;
 
     if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+      await this.renderInOrder(newFiles, this.diffs);
     }
-
-    this._updateDiffCursor();
+    this.updateDiffCursor();
     this.diffCursor.reInitAndUpdateStops();
   }
 
   // private but used in test
-  _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+  clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
     for (const diff of collapsedDiffs) {
       diff.cancel();
       diff.clearDiffContent();
@@ -1345,31 +2222,31 @@
    *
    * @param initialCount The total number of paths in the pass.
    */
-  async _renderInOrder(
-    files: PatchSetFile[],
-    diffElements: GrDiffHost[],
-    initialCount: number
-  ) {
+  async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
     this.reporting.time(Timing.FILE_EXPAND_ALL);
 
     for (const file of files) {
       const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    await asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      const diffElem = this._findDiffByPath(path, diffElements);
+      const diffElem = this.findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
           new Error(`Did not find <gr-diff-host> element for ${path}`)
         );
-        return Promise.resolve();
+        return;
+      }
+      diffElem.prefetchDiff();
+    }
+
+    await asyncForeach(files, async (file, cancel) => {
+      const path = file.path;
+      this.cancelForEachDiff = cancel;
+
+      const diffElem = this.findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return;
       }
       if (!this.diffPrefs) {
         throw new Error('diffPrefs must be set');
@@ -1381,18 +2258,18 @@
       // control over which diffs were actually seen. And for lots of diffs
       // that would even be a problem for write QPS quota.
       if (
-        this._loggedIn &&
+        this.loggedIn &&
         !this.diffPrefs.manual_review &&
-        initialCount === 1
+        files.length === 1
       ) {
-        this._reviewFile(path, true);
+        this.reviewFile(path, true);
       }
-      return diffElem.reload();
+      await diffElem.reload();
     });
 
-    this._cancelForEachDiff = undefined;
+    this.cancelForEachDiff = undefined;
     this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
-      count: initialCount,
+      count: files.length,
       height: this.clientHeight,
     });
     /* Block diff cursor from auto scrolling after files are done rendering.
@@ -1411,17 +2288,17 @@
   }
 
   /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) {
-      this._cancelForEachDiff();
+  private cancelDiffs() {
+    if (this.cancelForEachDiff) {
+      this.cancelForEachDiff();
     }
-    this._forEachDiff(d => d.cancel());
+    this.forEachDiff(d => d.cancel());
   }
 
   /**
    * In the given NodeList of diff elements, find the diff for the given path.
    */
-  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+  private findDiffByPath(path: string, diffElements: GrDiffHost[]) {
     for (let i = 0; i < diffElements.length; i++) {
       if (diffElements[i].path === path) {
         return diffElements[i];
@@ -1430,8 +2307,9 @@
     return undefined;
   }
 
-  _handleEscKey() {
-    this._displayLine = false;
+  // Private but used in tests.
+  handleEscKey() {
+    this.displayLine = false;
   }
 
   /**
@@ -1439,27 +2317,24 @@
    * debouncer so that the file list doesn't flash gray when the API requests
    * are reasonably fast.
    */
-  _loadingChanged(loading?: boolean) {
+  private loadingChanged() {
+    const loading = this.loading;
     this.loadingTask = debounce(
       this.loadingTask,
       () => {
         // Only show set the loading if there have been files loaded to show. In
         // this way, the gray loading style is not shown on initial loads.
-        this.classList.toggle('loading', loading && !!this._files.length);
+        this.classList.toggle('loading', loading && !!this.files.length);
       },
       LOADING_DEBOUNCE_INTERVAL
     );
   }
 
-  _editModeChanged(editMode?: boolean) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed?: boolean) {
+  private computeReviewedClass(isReviewed?: boolean) {
     return isReviewed ? 'isReviewed' : '';
   }
 
-  _computeReviewedText(isReviewed?: boolean) {
+  private computeReviewedText(isReviewed?: boolean) {
     return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
   }
 
@@ -1467,7 +2342,7 @@
    * Given a file path, return whether that path should have visible size bars
    * and be included in the size bars calculation.
    */
-  _showBarsForPath(path?: string) {
+  private showBarsForPath(path?: string) {
     return (
       path !== SpecialFilePath.COMMIT_MESSAGE &&
       path !== SpecialFilePath.MERGE_LIST
@@ -1476,16 +2351,12 @@
 
   /**
    * Compute size bar layout values from the file list.
+   * Private but used in tests.
    */
-  _computeSizeBarLayout(
-    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
-  ) {
+  computeSizeBarLayout() {
     const stats: SizeBarLayout = createDefaultSizeBarLayout();
-    if (!shownFilesRecord || !shownFilesRecord.base) {
-      return stats;
-    }
-    shownFilesRecord.base
-      .filter(f => this._showBarsForPath(f.__path))
+    this.shownFiles
+      .filter(f => this.showBarsForPath(f.__path))
       .forEach(f => {
         if (f.lines_inserted) {
           stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
@@ -1507,14 +2378,15 @@
 
   /**
    * Get the width of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxInserted === 0 ||
       !file.lines_inserted ||
-      !this._showBarsForPath(file.__path)
+      !this.showBarsForPath(file.__path)
     ) {
       return 0;
     }
@@ -1525,22 +2397,24 @@
 
   /**
    * Get the x-offset of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (!file || !stats) return;
-    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+    return stats.maxAdditionWidth - this.computeBarAdditionWidth(file, stats);
   }
 
   /**
    * Get the width of the deletion bar for a file.
+   * Private but used in tests.
    */
-  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxDeleted === 0 ||
       !file.lines_deleted ||
-      !this._showBarsForPath(file.__path)
+      !this.showBarsForPath(file.__path)
     ) {
       return 0;
     }
@@ -1552,15 +2426,16 @@
   /**
    * Get the x-offset of the deletion bar for a file.
    */
-  _computeBarDeletionX(stats: SizeBarLayout) {
+  private computeBarDeletionX(stats: SizeBarLayout) {
     return stats.deletionOffset;
   }
 
-  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+  // Private but used in tests.
+  computeSizeBarsClass(path?: string) {
     let hideClass = '';
-    if (!showSizeBars) {
+    if (!this.showSizeBars) {
       hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
+    } else if (!this.showBarsForPath(path)) {
       hideClass = 'invisible';
     }
     return `sizeBars ${hideClass}`;
@@ -1572,18 +2447,15 @@
    * Ideally, there should be a better way to enforce the expectation of the
    * dependencies between dynamic endpoints.
    */
-  _computeShowDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string,
-    summaryEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      summaryEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length &&
-      headerEndpoints.length === summaryEndpoints.length
+  private computeShowDynamicColumns() {
+    return !!(
+      this.dynamicHeaderEndpoints &&
+      this.dynamicContentEndpoints &&
+      this.dynamicSummaryEndpoints &&
+      this.dynamicHeaderEndpoints.length &&
+      this.dynamicHeaderEndpoints.length ===
+        this.dynamicContentEndpoints.length &&
+      this.dynamicHeaderEndpoints.length === this.dynamicSummaryEndpoints.length
     );
   }
 
@@ -1591,22 +2463,21 @@
    * Shows registered dynamic prepended columns iff the 'header', 'content'
    * endpoints are registered the exact same number of times.
    */
-  _computeShowPrependedDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length
+  private computeShowPrependedDynamicColumns() {
+    return !!(
+      this.dynamicPrependedHeaderEndpoints &&
+      this.dynamicPrependedContentEndpoints &&
+      this.dynamicPrependedHeaderEndpoints.length &&
+      this.dynamicPrependedHeaderEndpoints.length ===
+        this.dynamicPrependedContentEndpoints.length
     );
   }
 
   /**
    * Returns true if none of the inline diffs have been expanded.
+   * Private but used in tests.
    */
-  _noDiffsExpanded() {
+  noDiffsExpanded() {
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
@@ -1616,19 +2487,20 @@
    * rendering.
    *
    * @param index The index of the row being rendered.
+   * Private but used in tests.
    */
-  _reportRenderedRow(index: number) {
-    if (index === this._shownFiles.length - 1) {
+  reportRenderedRow(index: number) {
+    if (index === this.shownFiles.length - 1) {
       setTimeout(() => {
         this.reporting.timeEnd(Timing.FILE_RENDER, {
-          count: this._reportinShownFilesIncrement,
+          count: this.reportinShownFilesIncrement,
         });
       }, 1);
     }
-    return '';
   }
 
-  _reviewedTitle(reviewed?: boolean) {
+  // Private but used in tests.
+  reviewedTitle(reviewed?: boolean) {
     if (reviewed) {
       return 'Mark as not reviewed (shortcut: r)';
     }
@@ -1636,25 +2508,11 @@
     return 'Mark as reviewed (shortcut: r)';
   }
 
-  _handleReloadingDiffPreference() {
+  private handleReloadingDiffPreference() {
     this.userModel.getDiffPreferences();
   }
 
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path: string) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path: string) {
-    return computeTruncatedPath(path);
-  }
-
-  _getOldPath(file: NormalizedFileInfo) {
+  private getOldPath(file: NormalizedFileInfo) {
     // The gr-endpoint-decorator is waiting until all gr-endpoint-param
     // values are updated.
     // The old_path property is undefined for added files, and the
@@ -1664,9 +2522,3 @@
     return file.old_path ?? null;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list': GrFileList;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
deleted file mode 100644
index 67ca9be..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ /dev/null
@@ -1,823 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .row {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    /* The class defines a content visible only to screen readers */
-    .noCommentsScreenReaderText {
-      opacity: 0;
-      max-width: 1px;
-      overflow: hidden;
-      display: none;
-      vertical-align: top;
-    }
-    div[role='gridcell']
-      > div.comments
-      > span:empty
-      + span:empty
-      + span.noCommentsScreenReaderText {
-      /* inline-block instead of block, such that it can control width */
-      display: inline-block;
-    }
-    :host(.loading) .row {
-      opacity: 0.5;
-    }
-    :host(.editMode) .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    :host(.editMode) .showOnEdit {
-      display: initial;
-    }
-    .invisible {
-      visibility: hidden;
-    }
-    .header-row {
-      background-color: var(--background-color-secondary);
-    }
-    .controlRow {
-      align-items: center;
-      display: flex;
-      height: 2.25em;
-      justify-content: center;
-    }
-    .controlRow.invisible,
-    .show-hide.invisible {
-      display: none;
-    }
-    .reviewed,
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .reviewed {
-      display: inline-block;
-      text-align: left;
-      width: 1.5em;
-    }
-    .file-row {
-      cursor: pointer;
-    }
-    .file-row.expanded {
-      border-bottom: 1px solid var(--border-color);
-      position: -webkit-sticky;
-      position: sticky;
-      top: 0;
-      /* Has to visible above the diff view, and by default has a lower
-         z-index. setting to 1 places it directly above. */
-      z-index: 1;
-    }
-    .file-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    .file-row.selected {
-      background-color: var(--selection-background-color);
-    }
-    .file-row.expanded,
-    .file-row.expanded:hover {
-      background-color: var(--expanded-background-color);
-    }
-    .path {
-      cursor: pointer;
-      flex: 1;
-      /* Wrap it into multiple lines if too long. */
-      white-space: normal;
-      word-break: break-word;
-    }
-    .oldPath {
-      color: var(--deemphasized-text-color);
-    }
-    .header-stats {
-      text-align: center;
-      min-width: 7.5em;
-    }
-    .stats {
-      text-align: right;
-      min-width: 7.5em;
-    }
-    .comments {
-      padding-left: var(--spacing-l);
-      min-width: 7.5em;
-      white-space: nowrap;
-    }
-    .row:not(.header-row) .stats,
-    .total-stats {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      display: flex;
-    }
-    .sizeBars {
-      margin-left: var(--spacing-m);
-      min-width: 7em;
-      text-align: center;
-    }
-    .sizeBars.hide {
-      display: none;
-    }
-    .added,
-    .removed {
-      display: inline-block;
-      min-width: 3.5em;
-    }
-    .added {
-      color: var(--positive-green-text-color);
-    }
-    .removed {
-      color: var(--negative-red-text-color);
-      text-align: left;
-      min-width: 4em;
-      padding-left: var(--spacing-s);
-    }
-    .drafts {
-      color: var(--error-foreground);
-      font-weight: var(--font-weight-bold);
-    }
-    .show-hide-icon:focus {
-      outline: none;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-      width: 1.9em;
-    }
-    .fileListButton {
-      margin: var(--spacing-m);
-    }
-    .totalChanges {
-      justify-content: flex-end;
-      text-align: right;
-    }
-    .warning {
-      color: var(--deemphasized-text-color);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-      min-width: 2em;
-    }
-    gr-diff {
-      display: block;
-      overflow-x: auto;
-    }
-    .truncatedFileName {
-      display: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .reviewed {
-      margin-left: var(--spacing-xxl);
-      width: 15em;
-    }
-    .reviewedSwitch {
-      color: var(--link-color);
-      opacity: 0;
-      justify-content: flex-end;
-      width: 100%;
-    }
-    .reviewedSwitch:hover {
-      cursor: pointer;
-      opacity: 100;
-    }
-    .showParentButton {
-      line-height: var(--line-height-normal);
-      margin-bottom: calc(var(--spacing-s) * -1);
-      margin-left: var(--spacing-m);
-      margin-top: calc(var(--spacing-s) * -1);
-    }
-    .row:focus {
-      outline: none;
-    }
-    .row:hover .reviewedSwitch,
-    .row:focus-within .reviewedSwitch,
-    .row.expanded .reviewedSwitch {
-      opacity: 100;
-    }
-    .reviewedLabel {
-      color: var(--deemphasized-text-color);
-      margin-right: var(--spacing-l);
-      opacity: 0;
-    }
-    .reviewedLabel.isReviewed {
-      display: initial;
-      opacity: 100;
-    }
-    .editFileControls {
-      width: 7em;
-    }
-    .markReviewed:focus {
-      outline: none;
-    }
-    .markReviewed,
-    .pathLink {
-      display: inline-block;
-      margin: -2px 0;
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .pathLink:hover span.fullFileName,
-    .pathLink:hover span.truncatedFileName {
-      text-decoration: underline;
-    }
-
-    /** copy on file path **/
-    .pathLink gr-copy-clipboard,
-    .oldPath gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: bottom;
-      --gr-button-padding: 0px;
-    }
-    .row:focus-within gr-copy-clipboard,
-    .row:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-
-    @media screen and (max-width: 1200px) {
-      gr-endpoint-decorator.extra-col {
-        display: none;
-      }
-    }
-
-    @media screen and (max-width: 1000px) {
-      .reviewed {
-        display: none;
-      }
-    }
-
-    @media screen and (max-width: 800px) {
-      .desktop {
-        display: none;
-      }
-      .mobile {
-        display: block;
-      }
-      .row.selected {
-        background-color: var(--view-background-color);
-      }
-      .stats {
-        display: none;
-      }
-      .reviewed,
-      .status {
-        justify-content: flex-start;
-      }
-      .comments {
-        min-width: initial;
-      }
-      .expanded .fullFileName,
-      .truncatedFileName {
-        display: inline;
-      }
-      .expanded .truncatedFileName,
-      .fullFileName {
-        display: none;
-      }
-    }
-    :host(.hideComments) {
-      --gr-comment-thread-display: none;
-    }
-  </style>
-  <h3 class="assistive-tech-only">File list</h3>
-  <div
-    id="container"
-    on-click="_handleFileListClick"
-    role="grid"
-    aria-label="Files list"
-  >
-    <div class="header-row row" role="row">
-      <!-- endpoint: change-view-file-list-header-prepend -->
-      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicPrependedHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator
-            class="prepended-col"
-            name$="[[headerEndpoint]]"
-            role="columnheader"
-          >
-            <gr-endpoint-param name="change" value="[[change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="files" value="[[_files]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <div class="path" role="columnheader">File</div>
-      <div class="comments desktop" role="columnheader">Comments</div>
-      <div class="comments mobile" role="columnheader" title="Comments">C</div>
-      <div class="sizeBars desktop" role="columnheader">Size</div>
-      <div class="header-stats" role="columnheader">Delta</div>
-      <!-- endpoint: change-view-file-list-header -->
-      <template is="dom-if" if="[[_showDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator
-            class="extra-col"
-            name$="[[headerEndpoint]]"
-            role="columnheader"
-          >
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div
-        class="reviewed hideOnEdit"
-        hidden$="[[!_loggedIn]]"
-        aria-hidden="true"
-      ></div>
-      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
-      <div class="show-hide" aria-hidden="true"></div>
-    </div>
-
-    <template
-      is="dom-repeat"
-      items="[[_shownFiles]]"
-      id="files"
-      as="file"
-      initial-count="[[fileListIncrement]]"
-      target-framerate="1"
-    >
-      [[_reportRenderedRow(index)]]
-      <div class="stickyArea">
-        <div
-          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computePatchSetFile(file)]]"
-          tabindex="-1"
-          role="row"
-        >
-          <!-- endpoint: change-view-file-list-content-prepend -->
-          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicPrependedContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <gr-endpoint-decorator
-                class="prepended-col"
-                name="[[contentEndpoint]]"
-                role="gridcell"
-              >
-                <gr-endpoint-param name="change" value="[[change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="path" value="[[file.__path]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="oldPath" value="[[_getOldPath(file)]]">
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </template>
-          </template>
-          <!-- TODO: Remove data-url as it appears its not used -->
-          <span
-            data-url="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
-            class="path"
-            role="gridcell"
-          >
-            <a
-              class="pathLink"
-              href$="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
-            >
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="fullFileName"
-              >
-                [[_computeDisplayPath(file.__path)]]
-              </span>
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="truncatedFileName"
-              >
-                [[_computeTruncatedPath(file.__path)]]
-              </span>
-              <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
-              <gr-copy-clipboard
-                hideInput=""
-                text="[[file.__path]]"
-              ></gr-copy-clipboard>
-            </a>
-            <template is="dom-if" if="[[file.old_path]]">
-              <div class="oldPath" title$="[[file.old_path]]">
-                [[file.old_path]]
-                <gr-copy-clipboard
-                  hideInput=""
-                  text="[[file.old_path]]"
-                ></gr-copy-clipboard>
-              </div>
-            </template>
-          </span>
-          <div role="gridcell">
-            <div class="comments desktop">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsString(changeComments, patchRange, file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-              -->[[_computeCommentsString(changeComments, patchRange, file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- Screen readers read the following content only if 2 other
-              spans in the parent div is empty. The content is not visible on
-              the page.
-              Without this span, screen readers don't navigate correctly inside
-              table, because empty div doesn't rendered. For example, VoiceOver
-              jumps back to the whole table.
-              We can use &nbsp instead, but it sounds worse.
-              -->
-                No comments
-              </span>
-            </div>
-            <div class="comments mobile">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsStringMobile(changeComments, patchRange,
-                file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-             -->[[_computeCommentsStringMobile(changeComments, patchRange,
-                file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- The same as for desktop comments -->
-                No comments
-              </span>
-            </div>
-          </div>
-          <div class="desktop" role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-              gridcell always visible for screen readers.
-              For example, without a nested div screen readers pronounce the
-              "Commit message" row content with incorrect column headers.
-            -->
-            <div
-              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
-              aria-label="A bar that represents the addition and deletion ratio for the current file"
-            >
-              <svg width="61" height="8">
-                <rect
-                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--positive-green-text-color)"
-                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-                <rect
-                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--negative-red-text-color)"
-                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-              </svg>
-            </div>
-          </div>
-          <div class="stats" role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-            gridcell always visible for screen readers.
-            For example, without a nested div screen readers pronounce the
-            "Commit message" row content with incorrect column headers.
-            -->
-            <div class$="[[_computeClass('', file.__path)]]">
-              <span
-                class="added"
-                tabindex="0"
-                aria-label$="[[file.lines_inserted]] lines added"
-                hidden$="[[file.binary]]"
-              >
-                +[[file.lines_inserted]]
-              </span>
-              <span
-                class="removed"
-                tabindex="0"
-                aria-label$="[[file.lines_deleted]] lines removed"
-                hidden$="[[file.binary]]"
-              >
-                -[[file.lines_deleted]]
-              </span>
-              <span
-                class$="[[_computeBinaryClass(file.size_delta)]]"
-                hidden$="[[!file.binary]]"
-              >
-                [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-                file.size_delta)]]
-              </span>
-            </div>
-          </div>
-          <!-- endpoint: change-view-file-list-content -->
-          <template is="dom-if" if="[[_showDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
-                <gr-endpoint-decorator
-                  class="extra-col"
-                  name="[[contentEndpoint]]"
-                >
-                  <gr-endpoint-param name="change" value="[[change]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="path" value="[[file.__path]]">
-                  </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </div>
-            </template>
-          </template>
-          <div
-            class="reviewed hideOnEdit"
-            role="gridcell"
-            hidden$="[[!_loggedIn]]"
-          >
-            <span
-              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
-              aria-hidden$="[[!file.isReviewed]]"
-              >Reviewed</span
-            >
-            <!-- Do not use input type="checkbox" with hidden input and
-                  visible label here. Screen readers don't read/interract
-                  correctly with such input.
-              -->
-            <span
-              class="reviewedSwitch"
-              role="switch"
-              tabindex="0"
-              on-click="_reviewedClick"
-              on-keydown="_reviewedClick"
-              aria-label="Reviewed"
-              aria-checked$="[[_booleanToString(file.isReviewed)]]"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-                preserve focus outline for keyboard navigation -->
-              <span
-                tabindex="-1"
-                class="markReviewed"
-                title$="[[_reviewedTitle(file.isReviewed)]]"
-                >[[_computeReviewedText(file.isReviewed)]]</span
-              >
-            </span>
-          </div>
-          <div
-            class="editFileControls showOnEdit"
-            role="gridcell"
-            aria-hidden$="[[!editMode]]"
-          >
-            <template is="dom-if" if="[[editMode]]">
-              <gr-edit-file-controls
-                class$="[[_computeClass('', file.__path)]]"
-                file-path="[[file.__path]]"
-              ></gr-edit-file-controls>
-            </template>
-          </div>
-          <div class="show-hide" role="gridcell">
-            <!-- Do not use input type="checkbox" with hidden input and
-                visible label here. Screen readers don't read/interract
-                correctly with such input.
-            -->
-            <span
-              class="show-hide"
-              data-path$="[[file.__path]]"
-              data-expand="true"
-              role="switch"
-              tabindex="0"
-              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
-              aria-label="Expand file"
-              on-click="_expandedClick"
-              on-keydown="_expandedClick"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-              preserve focus outline for keyboard navigation -->
-              <iron-icon
-                class="show-hide-icon"
-                tabindex="-1"
-                id="icon"
-                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
-              >
-              </iron-icon>
-            </span>
-          </div>
-        </div>
-        <template
-          is="dom-if"
-          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-        >
-          <gr-diff-host
-            no-auto-render=""
-            show-load-failure=""
-            display-line="[[_displayLine]]"
-            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
-            change-num="[[changeNum]]"
-            change="[[change]]"
-            patch-range="[[patchRange]]"
-            file="[[_computePatchSetFile(file)]]"
-            path="[[file.__path]]"
-            prefs="[[diffPrefs]]"
-            project-name="[[change.project]]"
-            no-render-on-prefs-change=""
-          ></gr-diff-host>
-        </template>
-      </div>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_computeShowNumCleanlyMerged(_cleanlyMergedPaths)]]"
-    >
-      <div class="row">
-        <!-- endpoint: change-view-file-list-content-prepend -->
-        <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-          <template
-            is="dom-repeat"
-            items="[[_dynamicPrependedContentEndpoints]]"
-            as="contentEndpoint"
-          >
-            <gr-endpoint-decorator
-              class="prepended-col"
-              name="[[contentEndpoint]]"
-              role="gridcell"
-            >
-              <gr-endpoint-param name="change" value="[[change]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedPaths"
-                value="[[_cleanlyMergedPaths]]"
-              >
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedOldPaths"
-                value="[[_cleanlyMergedOldPaths]]"
-              >
-              </gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </template>
-        </template>
-        <div role="gridcell">
-          <div>
-            <span class="cleanlyMergedText">
-              [[_computeCleanlyMergedText(_cleanlyMergedPaths)]]
-            </span>
-            <gr-button
-              link
-              class="showParentButton"
-              on-click="_handleShowParent1"
-            >
-              Show Parent 1
-            </gr-button>
-          </div>
-        </div>
-      </div>
-    </template>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
-    <div class="total-stats">
-      <div>
-        <span
-          class="added"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.inserted]] lines added"
-        >
-          +[[_patchChange.inserted]]
-        </span>
-        <span
-          class="removed"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.deleted]] lines removed"
-        >
-          -[[_patchChange.deleted]]
-        </span>
-      </div>
-    </div>
-    <!-- endpoint: change-view-file-list-summary -->
-    <template is="dom-if" if="[[_showDynamicColumns]]">
-      <template
-        is="dom-repeat"
-        items="[[_dynamicSummaryEndpoints]]"
-        as="summaryEndpoint"
-      >
-        <gr-endpoint-decorator class="extra-col" name="[[summaryEndpoint]]">
-          <gr-endpoint-param
-            name="change"
-            value="[[change]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </template>
-    <!-- Empty div here exists to keep spacing in sync with file rows. -->
-    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-    <div class="editFileControls showOnEdit"></div>
-    <div class="show-hide"></div>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
-    <div class="total-stats">
-      <span
-        class="added"
-        aria-label$="Total bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
-      >
-        [[_formatBytes(_patchChange.size_delta_inserted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_inserted)]]
-      </span>
-      <span
-        class="removed"
-        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
-      >
-        [[_formatBytes(_patchChange.size_delta_deleted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_deleted)]]
-      </span>
-    </div>
-  </div>
-  <div
-    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
-  >
-    <gr-button
-      class="fileListButton"
-      id="incrementButton"
-      link=""
-      on-click="_incrementNumFilesShown"
-    >
-      [[_computeIncrementText(numFilesShown, _files)]]
-    </gr-button>
-    <gr-tooltip-content
-      has-tooltip="[[_computeWarnShowAll(_files)]]"
-      show-icon="[[_computeWarnShowAll(_files)]]"
-      title$="[[_computeShowAllWarning(_files)]]"
-    >
-      <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        link=""
-        on-click="_showAllFiles"
-      >
-        [[_computeShowAllText(_files)]] </gr-button
-      ><!--
-  --></gr-tooltip-content>
-  </div>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{diffPrefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 8d82e41..d39d648 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -17,16 +17,13 @@
 import '../../../test/common-test-setup-karma';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import './gr-file-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {runA11yAudit} from '../../../test/a11y-test-utils';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   listenOnce,
   mockPromise,
   query,
-  spyRestApi,
   stubRestApi,
 } from '../../../test/test-utils';
 import {
@@ -34,6 +31,7 @@
   CommitId,
   EditPatchSetNum,
   NumericChangeId,
+  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RepoName,
@@ -49,24 +47,20 @@
   createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+} from '../../../constants/constants';
 import {queryAll, queryAndAssert} from '../../../utils/common-util';
 import {GrFileList, NormalizedFileInfo} from './gr-file-list';
-import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {IronIconElement} from '@polymer/iron-icon';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrEditFileControls} from '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 
-const commentApiMock = createCommentApiMockWithTemplateElement(
-  'gr-file-list-comment-api-mock',
-  html` <gr-file-list id="fileList"></gr-file-list> `
-);
-
-const basicFixture = fixtureFromElement(commentApiMock.is);
+const basicFixture = fixtureFromElement('gr-file-list');
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -77,19 +71,18 @@
 function createFilesByPath(count: number) {
   return Array(count)
     .fill(0)
-    .reduce((_filesByPath, _, idx) => {
-      _filesByPath[`'/file${idx}`] = {lines_inserted: 9};
-      return _filesByPath;
+    .reduce((filesByPath, _, idx) => {
+      filesByPath[`'/file${idx}`] = {lines_inserted: 9};
+      return filesByPath;
     }, {});
 }
 
 suite('gr-file-list tests', () => {
   let element: GrFileList;
-  let commentApiWrapper: any;
 
   let saveStub: sinon.SinonStub;
 
-  suite('basic tests', () => {
+  suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -101,13 +94,22 @@
       stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
       stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
+      element = basicFixture.instantiate();
 
-      element._loading = false;
-      element.diffPrefs = {} as DiffPreferencesInfo;
+      element.loading = false;
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
       element.numFilesShown = 200;
       element.patchRange = {
         basePatchNum: 'PARENT' as BasePatchSetNum,
@@ -116,6 +118,9 @@
       saveStub = sinon
         .stub(element, '_saveReviewedState')
         .callsFake(() => Promise.resolve());
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to complete.
+      await flush();
     });
 
     test('renders', () => {
@@ -126,9 +131,6 @@
         </h3>
         <div aria-label="Files list" id="container" role="grid">
           <div class="header-row row" role="row">
-            <dom-if style="display: none;">
-              <template is="dom-if"> </template>
-            </dom-if>
             <div class="path" role="columnheader">File</div>
             <div class="comments desktop" role="columnheader">Comments</div>
             <div class="comments mobile" role="columnheader" title="Comments">
@@ -136,60 +138,10 @@
             </div>
             <div class="desktop sizeBars" role="columnheader">Size</div>
             <div class="header-stats" role="columnheader">Delta</div>
-            <dom-if style="display: none;">
-              <template is="dom-if"> </template>
-            </dom-if>
-            <div
-              aria-hidden="true"
-              class="hideOnEdit reviewed"
-              hidden="true"
-            ></div>
+            <div aria-hidden="true" class="hideOnEdit reviewed" hidden=""></div>
             <div aria-hidden="true" class="editFileControls showOnEdit"></div>
             <div aria-hidden="true" class="show-hide"></div>
           </div>
-          <dom-repeat
-            as="file"
-            id="files"
-            style="display: none;"
-            target-framerate="1"
-          >
-            <template is="dom-repeat"> </template>
-          </dom-repeat>
-          <dom-if style="display: none;">
-            <template is="dom-if"> </template>
-          </dom-if>
-        </div>
-        <div class="row totalChanges" hidden="true">
-          <div class="total-stats">
-            <div>
-              <span aria-label="Total 0 lines added" class="added" tabindex="0">
-                +0
-              </span>
-              <span
-                aria-label="Total 0 lines removed"
-                class="removed"
-                tabindex="0"
-              >
-                -0
-              </span>
-            </div>
-          </div>
-          <dom-if style="display: none;">
-            <template is="dom-if"> </template>
-          </dom-if>
-          <div class="hideOnEdit reviewed" hidden="true"></div>
-          <div class="editFileControls showOnEdit"></div>
-          <div class="show-hide"></div>
-        </div>
-        <div class="row totalChanges" hidden="true">
-          <div class="total-stats">
-            <span aria-label="Total bytes inserted: +/-0 B " class="added">
-              +/-0 B
-            </span>
-            <span aria-label="Total bytes removed: +/-0 B" class="removed">
-              +/-0 B
-            </span>
-          </div>
         </div>
         <div class="controlRow invisible row">
           <gr-button
@@ -220,9 +172,9 @@
         ></gr-diff-preferences-dialog>`);
     });
 
-    test('renders file row', () => {
-      element._filesByPath = createFilesByPath(1);
-      flush();
+    test('renders file row', async () => {
+      element.filesByPath = createFilesByPath(1);
+      await element.updateComplete;
       const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
       expect(fileRows?.[0]).dom.equal(/* HTML */ `<div
         class="file-row row"
@@ -230,9 +182,6 @@
         role="row"
         tabindex="-1"
       >
-        <dom-if style="display: none;">
-          <template is="dom-if"> </template>
-        </dom-if>
         <span class="path" role="gridcell">
           <a class="pathLink">
             <span class="fullFileName" title="'/file0"> '/file0 </span>
@@ -240,9 +189,6 @@
             <gr-file-status-chip> </gr-file-status-chip>
             <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
           </a>
-          <dom-if style="display: none;">
-            <template is="dom-if"> </template>
-          </dom-if>
         </span>
         <div role="gridcell">
           <div class="comments desktop">
@@ -257,7 +203,7 @@
         <div class="desktop" role="gridcell">
           <div
             aria-label="A bar that represents the addition and deletion ratio for the current file"
-            class="sizeBars"
+            class="hide sizeBars"
           ></div>
         </div>
         <div class="stats" role="gridcell">
@@ -268,35 +214,14 @@
             <span aria-label="0 lines removed" class="removed" tabindex="0">
               -0
             </span>
-            <span hidden="true"> +/-0 B </span>
+            <span hidden=""> +/-0 B </span>
           </div>
         </div>
-        <dom-if style="display: none;">
-          <template is="dom-if"> </template>
-        </dom-if>
-        <div class="hideOnEdit reviewed" hidden="true" role="gridcell">
-          <span aria-hidden="true" class="reviewedLabel"> Reviewed </span>
-          <span
-            aria-checked="false"
-            aria-label="Reviewed"
-            class="reviewedSwitch"
-            role="switch"
-            tabindex="0"
-          >
-            <span
-              class="markReviewed"
-              tabindex="-1"
-              title="Mark as reviewed (shortcut: r)"
-            >
-              MARK REVIEWED
-            </span>
-          </span>
-        </div>
-        <div class="editFileControls showOnEdit" role="gridcell">
-          <dom-if style="display: none;">
-            <template is="dom-if"> </template>
-          </dom-if>
-        </div>
+        <div
+          aria-hidden="true"
+          class="editFileControls showOnEdit"
+          role="gridcell"
+        ></div>
         <div class="show-hide" role="gridcell">
           <span
             aria-checked="false"
@@ -307,18 +232,24 @@
             role="switch"
             tabindex="0"
           >
-            <iron-icon class="show-hide-icon" id="icon" tabindex="-1">
+            <iron-icon
+              class="show-hide-icon"
+              id="icon"
+              tabindex="-1"
+              icon="gr-icons:expand-more"
+            >
             </iron-icon>
           </span>
         </div>
       </div>`);
     });
 
-    test('correct number of files are shown', () => {
+    test('correct number of files are shown', async () => {
       element.fileListIncrement = 300;
-      element._filesByPath = createFilesByPath(500);
+      element.filesByPath = createFilesByPath(500);
+      await element.updateComplete;
+      await flush();
 
-      flush();
       assert.equal(
         queryAll<HTMLDivElement>(element, '.file-row').length,
         element.numFilesShown
@@ -338,22 +269,25 @@
       );
 
       MockInteractions.tap(queryAndAssert<GrButton>(element, '#showAllButton'));
-      flush();
+      await element.updateComplete;
+      await flush();
 
       assert.equal(element.numFilesShown, 500);
-      assert.equal(element._shownFiles.length, 500);
+      assert.equal(element.shownFiles.length, 500);
       assert.isTrue(controlRow.classList.contains('invisible'));
     });
 
-    test('rendering each row calls the _reportRenderedRow method', () => {
-      const renderedStub = sinon.stub(element, '_reportRenderedRow');
-      element._filesByPath = createFilesByPath(10);
+    test('rendering each row calls the reportRenderedRow method', async () => {
+      const renderedStub = sinon.stub(element, 'reportRenderedRow');
+      element.filesByPath = createFilesByPath(10);
+      await element.updateComplete;
+
       assert.equal(queryAll<HTMLDivElement>(element, '.file-row').length, 10);
       assert.equal(renderedStub.callCount, 10);
     });
 
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
+    test('calculate totals for patch number', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {
           lines_inserted: 9,
           size: 0,
@@ -377,19 +311,21 @@
           size: 100,
         },
       };
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      let patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with a commit message that isn't the first file.
-      element._filesByPath = {
+      element.filesByPath = {
         'file_added_in_rev2.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
@@ -413,19 +349,21 @@
           size_delta: 0,
         },
       };
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with no commit message.
-      element._filesByPath = {
+      element.filesByPath = {
         'file_added_in_rev2.txt': {
           lines_inserted: 1,
           lines_deleted: 1,
@@ -439,19 +377,21 @@
           size_delta: 0,
         },
       };
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
+      element.filesByPath = {
         'file_added_in_rev2.txt': {
           lines_inserted: 1,
           size: 0,
@@ -463,19 +403,22 @@
           size_delta: 0,
         },
       };
-      assert.deepEqual(element._patchChange, {
+      await element.updateComplete;
+
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 1,
         deleted: 1,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('binary only files', () => {
-      element._filesByPath = {
+    test('binary only files', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {
           lines_inserted: 9,
           size: 0,
@@ -484,19 +427,22 @@
         file_binary_1: {binary: true, size_delta: 10, size: 100},
         file_binary_2: {binary: true, size_delta: -5, size: 120},
       };
-      assert.deepEqual(element._patchChange, {
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 0,
         deleted: 0,
         size_delta_inserted: 10,
         size_delta_deleted: -5,
         total_size: 220,
       });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isTrue(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('binary and regular files', () => {
-      element._filesByPath = {
+    test('binary and regular files', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {
           lines_inserted: 9,
           size: 0,
@@ -511,18 +457,21 @@
           size_delta: 0,
         },
       };
-      assert.deepEqual(element._patchChange, {
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 10,
         deleted: 5,
         size_delta_inserted: 10,
         size_delta_deleted: -5,
         total_size: 220,
       });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('_formatBytes function', () => {
+    test('formatBytes function', () => {
       const table = {
         '64': '+64 B',
         '1023': '+1023 B',
@@ -537,11 +486,11 @@
         '0': '+/-0 B',
       };
       for (const [bytes, expected] of Object.entries(table)) {
-        assert.equal(element._formatBytes(Number(bytes)), expected);
+        assert.equal(element.formatBytes(Number(bytes)), expected);
       }
     });
 
-    test('_formatPercentage function', () => {
+    test('formatPercentage function', () => {
       const table = [
         {size: 100, delta: 100, display: ''},
         {size: 195060, delta: 64, display: '(+0%)'},
@@ -553,7 +502,7 @@
 
       for (const item of table) {
         assert.equal(
-          element._formatPercentage(item.size, item.delta),
+          element.formatPercentage(item.size, item.delta),
           item.display
         );
       }
@@ -576,224 +525,253 @@
         patchNum: 2 as RevisionPatchSetNum,
       };
 
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
         '2c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1 draft'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1 draft'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1d'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1d'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: 'myfile.txt', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
         '1c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: 'file_added_in_rev2.txt', size: 0, size_delta: 0}
-        ),
-        ''
-      );
-      assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo2,
-          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
-        ),
+        element.computeDraftsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
         '1c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2 drafts'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2 drafts'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2d'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2d'
       );
+      element.patchRange = parentTo2;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo2,
-          {__path: 'myfile.txt', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
         '2c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
@@ -802,21 +780,21 @@
       );
     });
 
-    test('_reviewedTitle', () => {
+    test('reviewedTitle', () => {
       assert.equal(
-        element._reviewedTitle(true),
+        element.reviewedTitle(true),
         'Mark as not reviewed (shortcut: r)'
       );
 
       assert.equal(
-        element._reviewedTitle(false),
+        element.reviewedTitle(false),
         'Mark as reviewed (shortcut: r)'
       );
     });
 
     suite('keyboard shortcuts', () => {
-      setup(() => {
-        element._filesByPath = {
+      setup(async () => {
+        element.filesByPath = {
           '/COMMIT_MSG': {size: 0, size_delta: 0},
           'file_added_in_rev2.txt': {size: 0, size_delta: 0},
           'myfile.txt': {size: 0, size_delta: 0},
@@ -828,6 +806,8 @@
         };
         element.change = {_number: 42 as NumericChangeId} as ParsedChangeInfo;
         element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
+        await flush();
       });
 
       test('toggle left diff via shortcut', () => {
@@ -842,9 +822,7 @@
         diffsStub.restore();
       });
 
-      test('keyboard shortcuts', () => {
-        flush();
-
+      test('keyboard shortcuts', async () => {
         const items = [...queryAll<HTMLDivElement>(element, '.file-row')];
         element.fileCursor.stops = items;
         element.fileCursor.setCursorAtIndex(0);
@@ -904,67 +882,69 @@
         assert.isTrue(createCommentInPlaceStub.called);
       });
 
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath!);
-        sinon.stub(element, '_expandedFilesChanged');
-        flush();
+      test('i key shows/hides selected inline diff', async () => {
+        const paths = Object.keys(element.filesByPath!);
+        sinon.stub(element, 'expandedFilesChanged');
         const files = [...queryAll<HTMLDivElement>(element, '.file-row')];
         element.fileCursor.stops = files;
         element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
 
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[0]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[0]);
 
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
 
         element.fileCursor.setCursorAtIndex(1);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[1]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[1]);
 
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
+        await element.updateComplete;
         assert.equal(element.diffs.length, paths.length);
-        assert.equal(element._expandedFiles.length, paths.length);
+        assert.equal(element.expandedFiles.length, paths.length);
         for (const diff of element.diffs) {
-          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
+          assert.isTrue(element.expandedFiles.some(f => f.path === diff.path));
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
       });
 
-      test('r key toggles reviewed flag', () => {
+      test('r key toggles reviewed flag', async () => {
         const reducer = (accum: number, file: NormalizedFileInfo) =>
           file.isReviewed ? ++accum : accum;
-        const getNumReviewed = () => element._files.reduce(reducer, 0);
-        flush();
+        const getNumReviewed = () => element.files.reduce(reducer, 0);
+        await element.updateComplete;
 
         // Default state should be unreviewed.
         assert.equal(getNumReviewed(), 0);
 
         // Press the review key to toggle it (set the flag).
+        element.handleCursorNext(new KeyboardEvent('keydown'));
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flush();
+        await element.updateComplete;
         assert.equal(getNumReviewed(), 1);
 
         // Press the review key to toggle it (clear the flag).
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        await element.updateComplete;
         assert.equal(getNumReviewed(), 0);
       });
 
@@ -972,9 +952,9 @@
         let interact: Function;
 
         setup(() => {
-          const openCursorStub = sinon.stub(element, '_openCursorFile');
-          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
-          const expandStub = sinon.stub(element, '_toggleFileExpanded');
+          const openCursorStub = sinon.stub(element, 'openCursorFile');
+          const openSelectedStub = sinon.stub(element, 'openSelectedFile');
+          const expandStub = sinon.stub(element, 'toggleFileExpanded');
 
           interact = function () {
             openCursorStub.reset();
@@ -1016,9 +996,7 @@
         const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
 
         let noDiffsExpanded = true;
-        sinon
-          .stub(element, '_noDiffsExpanded')
-          .callsFake(() => noDiffsExpanded);
+        sinon.stub(element, 'noDiffsExpanded').callsFake(() => noDiffsExpanded);
 
         MockInteractions.pressAndReleaseKeyOn(
           element,
@@ -1054,25 +1032,26 @@
       });
     });
 
-    test('file review status', () => {
+    test('file review status', async () => {
       element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._filesByPath = {
+      element.filesByPath = {
         '/COMMIT_MSG': {size: 0, size_delta: 0},
         'file_added_in_rev2.txt': {size: 0, size_delta: 0},
         'myfile.txt': {size: 0, size_delta: 0},
       };
-      element._loggedIn = true;
+      element.loggedIn = true;
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
         basePatchNum: 'PARENT' as BasePatchSetNum,
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.fileCursor.setCursorAtIndex(0);
-      const reviewSpy = sinon.spy(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
 
-      flush();
-      queryAll(element, '.row:not(.header-row)');
+      const reviewSpy = sinon.spy(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      await element.updateComplete;
+
       const fileRows = queryAll(element, '.row:not(.header-row)');
       const checkSelector = 'span.reviewedSwitch[role="switch"]';
       const commitMsg = fileRows[0].querySelector(checkSelector);
@@ -1084,19 +1063,26 @@
       assert.equal(myFile!.getAttribute('aria-checked'), 'true');
 
       const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      assert.isOk(commitReviewLabel);
       const markReviewLabel = fileRows[0].querySelector('.markReviewed');
+      assert.isOk(markReviewLabel);
       assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
       assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
 
-      const clickSpy = sinon.spy(element, '_reviewedClick');
+      const clickSpy = sinon.spy(element, 'reviewedClick');
       MockInteractions.tap(markReviewLabel!);
+      await element.updateComplete;
+
       // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
       // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel!.textContent, 'MARK REVIEWED');
+      assert.isTrue(clickSpy.calledOnce);
       assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
       assert.isTrue(reviewSpy.calledOnce);
 
       MockInteractions.tap(markReviewLabel!);
+      await element.updateComplete;
+
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
       assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
       assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
@@ -1106,8 +1092,8 @@
       assert.isFalse(toggleExpandSpy.called);
     });
 
-    test('_handleFileListClick', () => {
-      element._filesByPath = {
+    test('handleFileListClick', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {size: 0, size_delta: 0},
         'f1.txt': {size: 0, size_delta: 0},
         'f2.txt': {size: 0, size_delta: 0},
@@ -1117,33 +1103,36 @@
         basePatchNum: 'PARENT' as BasePatchSetNum,
         patchNum: 2 as RevisionPatchSetNum,
       };
+      await element.updateComplete;
 
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
 
       const row = queryAndAssert(
         element,
         '.row[data-file=\'{"path":"f1.txt"}\']'
       );
 
-      // Click on the expand button, resulting in _toggleFileExpanded being
-      // called and not resulting in a call to _reviewFile.
+      // Click on the expand button, resulting in toggleFileExpanded being
+      // called and not resulting in a call to reviewFile.
       queryAndAssert<HTMLDivElement>(row, 'div.show-hide').click();
+      await element.updateComplete;
       assert.isTrue(clickSpy.calledOnce);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
 
       // Click inside the diff. This should result in no additional calls to
-      // _toggleFileExpanded or _reviewFile.
+      // toggleFileExpanded or reviewFile.
       queryAndAssert<GrDiffHost>(element, 'gr-diff-host').click();
+      await element.updateComplete;
       assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
     });
 
-    test('_handleFileListClick editMode', () => {
-      element._filesByPath = {
+    test('handleFileListClick editMode', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {size: 0, size_delta: 0},
         'f1.txt': {size: 0, size_delta: 0},
         'f2.txt': {size: 0, size_delta: 0},
@@ -1154,18 +1143,21 @@
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.editMode = true;
-      flush();
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+      await element.updateComplete;
 
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      // Tap the edit controls. Should be ignored by handleFileListClick.
       MockInteractions.tap(queryAndAssert(element, '.editFileControls'));
+      await element.updateComplete;
+
       assert.isTrue(clickSpy.calledOnce);
       assert.isFalse(toggleExpandSpy.called);
     });
 
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
+    test('checkbox shows/hides diff inline', async () => {
+      element.filesByPath = {
         'myfile.txt': {size: 0, size_delta: 0},
       };
       element.changeNum = 42 as NumericChangeId;
@@ -1174,8 +1166,8 @@
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.fileCursor.setCursorAtIndex(0);
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
       const fileRows = queryAll(element, '.row:not(.header-row)');
       // Because the label surrounds the input, the tap event is triggered
       // there first.
@@ -1185,15 +1177,17 @@
       const showHideLabel = showHideCheck!.querySelector('.show-hide-icon');
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'false');
       MockInteractions.tap(showHideLabel!);
+      await element.updateComplete;
+
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
       assert.notEqual(
-        element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+        element.expandedFiles.findIndex(f => f.path === 'myfile.txt'),
         -1
       );
     });
 
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
+    test('diff mode correctly toggles the diffs', async () => {
+      element.filesByPath = {
         'myfile.txt': {size: 0, size_delta: 0},
       };
       element.changeNum = 42 as NumericChangeId;
@@ -1201,28 +1195,30 @@
         basePatchNum: 'PARENT' as BasePatchSetNum,
         patchNum: 2 as RevisionPatchSetNum,
       };
-      const updateDiffPrefSpy = sinon.spy(element, '_updateDiffPreferences');
+      const updateDiffPrefSpy = sinon.spy(element, 'updateDiffPreferences');
       element.fileCursor.setCursorAtIndex(0);
-      flush();
+      await element.updateComplete;
 
       // Tap on a file to generate the diff.
       const row = queryAll(element, '.row:not(.header-row) span.show-hide')[0];
 
       MockInteractions.tap(row);
-      flush();
-      element.set('diffViewMode', 'UNIFIED_DIFF');
+
+      element.diffViewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+
       assert.isTrue(updateDiffPrefSpy.called);
     });
 
     test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
+      element.filesByPath = {
         '/COMMIT_MSG': {size: 0, size_delta: 0},
       };
       assert.isNotOk(query(element, 'expanded'));
     });
 
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
+    test('tapping row ignores links', async () => {
+      element.filesByPath = {
         '/COMMIT_MSG': {size: 0, size_delta: 0},
       };
       element.changeNum = 42 as NumericChangeId;
@@ -1230,8 +1226,8 @@
         basePatchNum: 'PARENT' as BasePatchSetNum,
         patchNum: 2 as RevisionPatchSetNum,
       };
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
       const commitMsgFile = queryAll(
         element,
         '.row:not(.header-row) a.pathLink'
@@ -1239,10 +1235,10 @@
 
       // Remove href attribute so the app doesn't route to a diff view
       commitMsgFile.removeAttribute('href');
-      const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
+      const togglePathSpy = sinon.spy(element, 'toggleFileExpanded');
 
       MockInteractions.tap(commitMsgFile);
-      flush();
+      await element.updateComplete;
       assert(togglePathSpy.notCalled, 'file is opened as diff view');
       assert.isNotOk(query(element, '.expanded'));
       assert.notEqual(
@@ -1251,19 +1247,26 @@
       );
     });
 
-    test('_toggleFileExpanded', () => {
+    test('toggleFileExpanded', async () => {
       const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {size: 0, size_delta: 0}};
-      const renderSpy = sinon.spy(element, '_renderInOrder');
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+      element.filesByPath = {[path]: {size: 0, size_delta: 0}};
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await flush();
+
+      const renderSpy = sinon.spy(element, 'renderInOrder');
+      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
 
       assert.equal(
         queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
         'gr-icons:expand-more'
       );
-      assert.equal(element._expandedFiles.length, 0);
-      element._toggleFileExpanded({path});
-      flush();
+      assert.equal(element.expandedFiles.length, 0);
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await flush();
+
       assert.equal(collapseStub.lastCall.args[0].length, 0);
       assert.equal(
         queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
@@ -1271,45 +1274,49 @@
       );
 
       assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flush();
+      assert.isTrue(element.expandedFiles.some(f => f.path === path));
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await flush();
 
       assert.equal(
         queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
         'gr-icons:expand-more'
       );
       assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element._expandedFiles.some(f => f.path === path));
+      assert.isFalse(element.expandedFiles.some(f => f.path === path));
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('expandAllDiffs and collapseAllDiffs', () => {
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sinon.stub(
-        element.diffCursor,
-        'handleDiffUpdate'
-      );
+    test('expandAllDiffs and collapseAllDiffs', async () => {
+      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
       const reInitStub = sinon.stub(element.diffCursor, 'reInitAndUpdateStops');
 
       const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {size: 0, size_delta: 0}};
+      element.filesByPath = {[path]: {size: 0, size_delta: 0}};
+      // Wait for diffs to be computed.
+      await element.updateComplete;
+      await flush();
       element.expandAllDiffs();
-      flush();
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await flush();
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-      assert.isTrue(reInitStub.calledOnce);
+      assert.isTrue(reInitStub.calledTwice);
       assert.equal(collapseStub.lastCall.args[0].length, 0);
 
       element.collapseAllDiffs();
-      flush();
-      assert.equal(element._expandedFiles.length, 0);
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await flush();
+      assert.equal(element.expandedFiles.length, 0);
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      assert.isTrue(cursorUpdateStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('_expandedFilesChanged', async () => {
-      sinon.stub(element, '_reviewFile');
+    test('expandedFilesChanged', async () => {
+      sinon.stub(element, 'reviewFile');
       const path = 'path/to/my/file.txt';
       const promise = mockPromise();
       const diffs = [
@@ -1335,11 +1342,13 @@
         },
       ];
       sinon.stub(element, 'diffs').get(() => diffs);
-      element.push('_expandedFiles', {path});
+      element.expandedFiles = element.expandedFiles.concat([{path}]);
+      await element.updateComplete;
+      await flush();
       await promise;
     });
 
-    test('_clearCollapsedDiffs', () => {
+    test('clearCollapsedDiffs', () => {
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
       // methods / properties that it isn't worth it.
@@ -1347,34 +1356,36 @@
         cancel: sinon.stub(),
         clearDiffContent: sinon.stub(),
       } as any;
-      element._clearCollapsedDiffs([diff]);
+      element.clearCollapsedDiffs([diff]);
       assert.isTrue(diff.cancel.calledOnce);
       assert.isTrue(diff.clearDiffContent.calledOnce);
     });
 
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
+    test('filesExpanded value updates to correct enum', async () => {
+      element.filesByPath = {
         'foo.bar': {size: 0, size_delta: 0},
         'baz.bar': {size: 0, size_delta: 0},
       };
-      flush();
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flush();
+      element.expandedFiles.push({path: 'baz.bar'});
+      element.expandedFilesChanged([{path: 'baz.bar'}]);
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flush();
+      element.expandedFiles.push({path: 'foo.bar'});
+      element.expandedFilesChanged([{path: 'foo.bar'}]);
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
       element.collapseAllDiffs();
-      flush();
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
       element.expandAllDiffs();
-      flush();
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', async () => {
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder', async () => {
+      const reviewStub = sinon.stub(element, 'reviewFile');
       let callCount = 0;
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
@@ -1408,18 +1419,14 @@
           },
         },
       ] as any;
-      element._renderInOrder(
-        [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}],
-        diffs,
-        3
-      );
-      await flush();
+      element.renderInOrder([{path: 'p2'}, {path: 'p1'}, {path: 'p0'}], diffs);
+      await element.updateComplete;
       assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', async () => {
-      element._loggedIn = true;
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder logged in', async () => {
+      element.loggedIn = true;
+      const reviewStub = sinon.stub(element, 'reviewFile');
       let callCount = 0;
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
@@ -1436,15 +1443,28 @@
           },
         },
       ] as any;
-      element._renderInOrder([{path: 'p2'}], diffs, 1);
-      await flush();
+      element.renderInOrder([{path: 'p2'}], diffs);
+      await element.updateComplete;
       assert.equal(reviewStub.callCount, 1);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', async () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true} as DiffPreferencesInfo;
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder respects diffPrefs.manual_review', async () => {
+      element.loggedIn = true;
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+        manual_review: true,
+      };
+      const reviewStub = sinon.stub(element, 'reviewFile');
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
       // methods / properties that it isn't worth it.
@@ -1459,17 +1479,19 @@
         },
       ] as any;
 
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
       assert.isFalse(reviewStub.called);
       delete element.diffPrefs.manual_review;
-      element._renderInOrder([{path: 'p'}], diffs, 1);
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
+      // Wait for renderInOrder to finish
       await flush();
       assert.isTrue(reviewStub.called);
       assert.isTrue(reviewStub.calledWithExactly('p', true));
     });
 
-    test('_loadingChanged fired from reload in debouncer', async () => {
+    test('loadingChanged fired from reload in debouncer', async () => {
       const reloadBlocker = mockPromise();
       stubRestApi('getChangeOrEditFiles').resolves({
         'foo.bar': {size: 0, size_delta: 0},
@@ -1480,14 +1502,17 @@
 
       element.changeNum = 123 as NumericChangeId;
       element.patchRange = {patchNum: 12 as RevisionPatchSetNum} as PatchRange;
-      element._filesByPath = {'foo.bar': {size: 0, size_delta: 0}};
+      element.filesByPath = {'foo.bar': {size: 0, size_delta: 0}};
       element.change = {
         ...createParsedChange(),
         _number: 123 as NumericChangeId,
       };
+      await element.updateComplete;
+      await flush();
 
       const reloaded = element.reload();
-      assert.isTrue(element._loading);
+      await element.updateComplete;
+      assert.isTrue(element.loading);
       assert.isFalse(element.classList.contains('loading'));
       element.loadingTask!.flush();
       assert.isTrue(element.classList.contains('loading'));
@@ -1495,15 +1520,14 @@
       reloadBlocker.resolve();
       await reloaded;
 
-      assert.isFalse(element._loading);
+      assert.isFalse(element.loading);
       element.loadingTask!.flush();
       assert.isFalse(element.classList.contains('loading'));
     });
 
-    test('_loadingChanged does not set class when there are no files', () => {
+    test('loadingChanged does not set class when there are no files', () => {
       const reloadBlocker = mockPromise();
       stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
-      sinon.stub(element, '_getReviewedFiles').resolves([]);
       element.changeNum = 123 as NumericChangeId;
       element.patchRange = {patchNum: 12 as RevisionPatchSetNum} as PatchRange;
       element.change = {
@@ -1512,7 +1536,7 @@
       };
       element.reload();
 
-      assert.isTrue(element._loading);
+      assert.isTrue(element.loading);
 
       element.loadingTask!.flush();
 
@@ -1554,12 +1578,13 @@
           basePatchNum: 'PARENT' as BasePatchSetNum,
           patchNum: 1 as RevisionPatchSetNum,
         };
+        await element.updateComplete;
         await flush();
       });
 
       test('displays cleanly merged file count', async () => {
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         const message = queryAndAssert<HTMLSpanElement>(
           element,
@@ -1580,7 +1605,7 @@
             'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
           });
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         const message = queryAndAssert(
           element,
@@ -1591,7 +1616,7 @@
 
       test('displays button for navigating to parent 1 base', async () => {
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         queryAndAssert(element, '.showParentButton');
       });
@@ -1611,9 +1636,9 @@
             },
           });
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
-        assert.deepEqual(element._cleanlyMergedOldPaths, [
+        assert.deepEqual(element.cleanlyMergedOldPaths, [
           'cleanlyMergedFileOldName.js',
         ]);
       });
@@ -1624,7 +1649,7 @@
           patchNum: 2 as RevisionPatchSetNum,
         };
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
@@ -1636,7 +1661,7 @@
           patchNum: EditPatchSetNum,
         };
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
@@ -1649,22 +1674,18 @@
       const diffStub = sinon
         .stub(GerritNav, 'getUrlForDiff')
         .returns('/c/gerrit/+/1/1/index.php');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
       const path = 'index.php';
-      assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          false
-        ),
-        '/c/gerrit/+/1/1/index.php'
-      );
+      element.editMode = false;
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
       diffStub.restore();
     });
 
@@ -1672,22 +1693,18 @@
       const diffStub = sinon
         .stub(GerritNav, 'getUrlForDiff')
         .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = false;
       const path = '/COMMIT_MSG';
-      assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          false
-        ),
-        '/c/gerrit/+/1/1//COMMIT_MSG'
-      );
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
       diffStub.restore();
     });
 
@@ -1695,20 +1712,19 @@
       const editStub = sinon
         .stub(GerritNav, 'getEditUrlForDiff')
         .returns('/c/gerrit/+/1/edit/index.php,edit');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
       const path = 'index.php';
       assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          true
-        ),
+        element.computeDiffURL(path),
         '/c/gerrit/+/1/edit/index.php,edit'
       );
       editStub.restore();
@@ -1718,20 +1734,19 @@
       const editStub = sinon
         .stub(GerritNav, 'getEditUrlForDiff')
         .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
       const path = '/COMMIT_MSG';
       assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          true
-        ),
+        element.computeDiffURL(path),
         '/c/gerrit/+/1/edit//COMMIT_MSG,edit'
       );
       editStub.restore();
@@ -1739,7 +1754,7 @@
   });
 
   suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
+    test('computeSizeBarLayout', async () => {
       const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
@@ -1748,37 +1763,39 @@
         deletionOffset: 0,
       };
 
-      assert.deepEqual(
-        element._computeSizeBarLayout(undefined),
-        defaultSizeBarLayout
-      );
-      assert.deepEqual(
-        element._computeSizeBarLayout(
-          {} as PolymerDeepPropertyChange<
-            NormalizedFileInfo[],
-            NormalizedFileInfo[]
-          >
-        ),
-        defaultSizeBarLayout
-      );
-      assert.deepEqual(
-        element._computeSizeBarLayout({base: []} as any),
-        defaultSizeBarLayout
-      );
+      element.files = [];
+      await element.updateComplete;
+      assert.deepEqual(element.computeSizeBarLayout(), defaultSizeBarLayout);
 
-      const files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 10000},
-        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 10000,
+          size_delta: 10000,
+          size: 10000,
+        },
+        {
+          __path: 'foo',
+          lines_inserted: 4,
+          lines_deleted: 10,
+          size_delta: 14,
+          size: 20,
+        },
+        {
+          __path: 'bar',
+          lines_inserted: 5,
+          lines_deleted: 8,
+          size_delta: 13,
+          size: 21,
+        },
       ];
-      const layout = element._computeSizeBarLayout({
-        base: files,
-      } as PolymerDeepPropertyChange<NormalizedFileInfo[], NormalizedFileInfo[]>);
+      await element.updateComplete;
+      const layout = element.computeSizeBarLayout();
       assert.equal(layout.maxInserted, 5);
       assert.equal(layout.maxDeleted, 10);
     });
 
-    test('_computeBarAdditionWidth', () => {
+    test('computeBarAdditionWidth', () => {
       const file = {
         __path: 'foo/bar.baz',
         lines_inserted: 5,
@@ -1796,27 +1813,27 @@
 
       // Uses half the space when file is half the largest addition and there
       // are no deletions.
-      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 30);
 
       // If there are no insertions, there is no width.
       stats.maxInserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // If the insertions is not present on the file, there is no width.
       stats.maxInserted = 10;
       file.lines_inserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // If the file is a commit message, returns zero.
       file.lines_inserted = 5;
       file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // Width bottoms-out at the minimum width.
       file.__path = 'stuff.txt';
       file.lines_inserted = 1;
       stats.maxInserted = 1000000;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 1.5);
     });
 
     test('_computeBarAdditionX', () => {
@@ -1834,10 +1851,10 @@
         maxDeletionWidth: 0,
         deletionOffset: 60,
       };
-      assert.equal(element._computeBarAdditionX(file, stats), 30);
+      assert.equal(element.computeBarAdditionX(file, stats), 30);
     });
 
-    test('_computeBarDeletionWidth', () => {
+    test('computeBarDeletionWidth', () => {
       const file = {
         __path: 'foo/bar.baz',
         lines_inserted: 0,
@@ -1855,42 +1872,41 @@
 
       // Uses a quarter the space when file is half the largest deletions and
       // there are equal additions.
-      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 15);
 
       // If there are no deletions, there is no width.
       stats.maxDeleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // If the deletions is not present on the file, there is no width.
       stats.maxDeleted = 10;
       file.lines_deleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // If the file is a commit message, returns zero.
       file.lines_deleted = 5;
       file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // Width bottoms-out at the minimum width.
       file.__path = 'stuff.txt';
       file.lines_deleted = 1;
       stats.maxDeleted = 1000000;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 1.5);
     });
 
     test('_computeSizeBarsClass', () => {
+      element.showSizeBars = false;
       assert.equal(
-        element._computeSizeBarsClass(false, 'foo/bar.baz'),
+        element.computeSizeBarsClass('foo/bar.baz'),
         'sizeBars hide'
       );
+      element.showSizeBars = true;
       assert.equal(
-        element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+        element.computeSizeBarsClass('/COMMIT_MSG'),
         'sizeBars invisible'
       );
-      assert.equal(
-        element._computeSizeBarsClass(true, 'foo/bar.baz'),
-        'sizeBars '
-      );
+      assert.equal(element.computeSizeBarsClass('foo/bar.baz'), 'sizeBars ');
     });
   });
 
@@ -1958,7 +1974,7 @@
         await setupDiff(diffs[i]);
       }
 
-      element._updateDiffCursor();
+      element.updateDiffCursor();
       element.diffCursor.handleDiffUpdate();
       return diffs;
     }
@@ -1974,22 +1990,31 @@
       stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
       stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
-      element.diffPrefs = {} as DiffPreferencesInfo;
+      element = basicFixture.instantiate();
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         project: 'testRepo' as RepoName,
       };
-      reviewFileStub = sinon.stub(element, '_reviewFile');
+      reviewFileStub = sinon.stub(element, 'reviewFile');
 
-      element._loading = false;
+      element.loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
-      element._filesByPath = {
+      element.filesByPath = {
         '/COMMIT_MSG': {lines_inserted: 9, size: 0, size_delta: 0},
         'file_added_in_rev2.txt': {
           lines_inserted: 1,
@@ -2005,7 +2030,7 @@
         },
       };
       element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._loggedIn = true;
+      element.loggedIn = true;
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
         basePatchNum: 'PARENT' as BasePatchSetNum,
@@ -2014,12 +2039,13 @@
       sinon
         .stub(window, 'fetch')
         .callsFake(() => Promise.resolve(new Response()));
-      await flush();
+      await element.updateComplete;
     });
 
     test('cursor with individually opened files', async () => {
       MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
+      await element.updateComplete;
+
       let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
@@ -2035,7 +2061,7 @@
       MockInteractions.tap(
         queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2043,7 +2069,7 @@
       // Keyboard shortcuts are still moving the file cursor, not the diff
       // cursor.
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2055,7 +2081,7 @@
       assert.equal(element.fileCursor.index, 1);
 
       MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
+      await element.updateComplete;
       diffs = await renderAndGetNewDiffs(1);
 
       // Two diffs should be rendered.
@@ -2074,7 +2100,7 @@
 
     test('cursor with toggle all files', async () => {
       MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-      await flush();
+      await element.updateComplete;
 
       const diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
@@ -2091,7 +2117,7 @@
       MockInteractions.tap(
         queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2099,7 +2125,7 @@
       // Keyboard shortcuts are still moving the file cursor, not the diff
       // cursor.
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
+      await element.updateComplete;
       assert.isFalse(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2117,7 +2143,7 @@
       let fileRows: NodeListOf<HTMLDivElement>;
 
       setup(() => {
-        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
+        sinon.stub(element, 'renderInOrder').returns(Promise.resolve());
         nextCommentStub = sinon.stub(
           element.diffCursor,
           'moveToNextCommentThread'
@@ -2126,72 +2152,77 @@
         fileRows = queryAll<HTMLDivElement>(element, '.row:not(.header-row)');
       });
 
-      test('n key with some files expanded', async () => {
+      test('correct number of files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        await element.updateComplete;
         assert.isTrue(nextChunkStub.calledOnce);
       });
 
       test('N key with some files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        await element.updateComplete;
         assert.isTrue(nextCommentStub.calledOnce);
       });
 
       test('n key with all files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.ALL);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        await element.updateComplete;
         assert.isTrue(nextChunkStub.calledOnce);
       });
 
       test('N key with all files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.ALL);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        await element.updateComplete;
         assert.isTrue(nextCommentStub.called);
       });
     });
 
-    test('_openSelectedFile behavior', async () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
+    test('openSelectedFile behavior', async () => {
+      const filesByPath = element.filesByPath;
+      element.filesByPath = {};
+      await element.updateComplete;
       const navStub = sinon.stub(GerritNav, 'navigateToDiff');
       // Noop when there are no files.
-      element._openSelectedFile();
+      element.openSelectedFile();
       assert.isFalse(navStub.called);
 
-      element.set('_filesByPath', _filesByPath);
-      await flush();
+      element.filesByPath = filesByPath;
+      await element.updateComplete;
       // Navigates when a file is selected.
-      element._openSelectedFile();
+      element.openSelectedFile();
       assert.isTrue(navStub.called);
     });
 
-    test('_displayLine', () => {
+    test('displayLine', () => {
       element.filesExpanded = FilesExpandedState.ALL;
 
-      element._displayLine = false;
-      element._handleCursorNext(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
+      element.displayLine = false;
+      element.handleCursorNext(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
 
-      element._displayLine = false;
-      element._handleCursorPrev(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
+      element.displayLine = false;
+      element.handleCursorPrev(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
 
-      element._displayLine = true;
-      element._handleEscKey();
-      assert.isFalse(element._displayLine);
+      element.displayLine = true;
+      element.handleEscKey();
+      assert.isFalse(element.displayLine);
     });
 
     suite('editMode behavior', () => {
@@ -2204,22 +2235,11 @@
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
-        await flush();
+        await element.updateComplete;
 
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
       });
-
-      test('_getReviewedFiles does not call API', () => {
-        const apiSpy = spyRestApi('getReviewedFiles');
-        element.editMode = true;
-        return element
-          ._getReviewedFiles(0 as NumericChangeId, {patchNum: 0} as PatchRange)
-          .then(files => {
-            assert.equal(files!.length, 0);
-            assert.isFalse(apiSpy.called);
-          });
-      });
     });
 
     test('editing actions', async () => {
@@ -2229,7 +2249,7 @@
       );
 
       element.editMode = true;
-      await flush();
+      await element.updateComplete;
 
       // Commit message should not have edit controls.
       const editControls = Array.from(
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 2988bc6..6bd3f62 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -27,9 +27,6 @@
   DetailedLabelInfo,
 } from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {classMap} from 'lit/directives/class-map';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
 
@@ -68,12 +65,6 @@
   @state()
   private selectedValueText = 'No value selected';
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   static override get styles() {
     return [
       sharedStyles,
@@ -87,8 +78,6 @@
         /* We want the :hover highlight to extend to the border of the dialog. */
         .labelNameCell {
           padding-left: var(--label-score-padding-left, 0);
-        }
-        .labelNameCell.newSubmitRequirements {
           width: 160px;
         }
         .selectedValueCell {
@@ -100,9 +89,6 @@
           white-space: nowrap;
         }
         .selectedValueCell {
-          width: 75%;
-        }
-        .selectedValueCell.newSubmitRequirements {
           width: 52%;
         }
         .labelMessage {
@@ -175,13 +161,7 @@
 
   override render() {
     return html`
-      <span
-        class=${classMap({
-          labelNameCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-        id="labelName"
-        aria-hidden="true"
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
         >${this.label?.name ?? ''}</span
       >
       ${this.renderButtonsCell()} ${this.renderSelectedValue()}
@@ -257,12 +237,7 @@
 
   private renderSelectedValue() {
     return html`
-      <div
-        class=${classMap({
-          selectedValueCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-      >
+      <div class="selectedValueCell">
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 27c445e..8e757da 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -24,10 +24,8 @@
   LabelNameToValueMap,
 } from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
-import {getAppContext} from '../../../services/app-context';
 import {
   getTriggerVotes,
-  showNewSubmitRequirements,
   computeLabels,
   Label,
   computeOrderedLabelValues,
@@ -48,8 +46,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -57,8 +53,6 @@
         .scoresTable {
           display: table;
           width: 100%;
-        }
-        .scoresTable.newSubmitRequirements {
           table-layout: fixed;
         }
         .mergedMessage,
@@ -91,19 +85,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = computeLabels(this.account, this.change);
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
@@ -145,13 +126,7 @@
   }
 
   private renderLabels(labels: Label[]) {
-    const newSubReqs = showNewSubmitRequirements(
-      this.flagsService,
-      this.change
-    );
-    return html`<div
-      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
-    >
+    return html`<div class="scoresTable">
       ${labels
         .filter(
           label =>
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 4bf9d10..f204d76 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -25,8 +25,6 @@
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -102,8 +100,6 @@
     `;
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
@@ -112,7 +108,6 @@
 
   private renderScore(score: Score, triggerVotes: string[]) {
     if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
       score.label &&
       triggerVotes.includes(score.label) &&
       !score.value?.includes(VOTE_RESET_TEXT)
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index f6b0ad4..1af37b3 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -14,14 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Subscription} from 'rxjs';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
 import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
-import {htmlTemplate} from './gr-messages-list_html';
 import {
   Shortcut,
   ShortcutSection,
@@ -29,7 +26,7 @@
 import {parseDate} from '../../../utils/date-util';
 import {MessageTag} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {
   ChangeId,
   ChangeMessageId,
@@ -38,17 +35,10 @@
   NumericChangeId,
   PatchSetNum,
   RepoName,
-  ReviewerUpdateInfo,
   VotingRangeInfo,
 } from '../../../types/common';
-import {
-  CommentThread,
-  isRobot,
-  LabelExtreme,
-} from '../../../utils/comment-util';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -56,8 +46,14 @@
 } from '../../../types/types';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {queryAll} from '../../../utils/common-util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {when} from 'lit/directives/when';
+import {ifDefined} from 'lit/directives/if-defined';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -168,6 +164,63 @@
 }
 
 /**
+ * Merges change messages and reviewer updates into one array. Also processes
+ * all messages and updates, aligns or massages some of the properties.
+ */
+function computeCombinedMessages(
+  messages: ChangeMessageInfo[],
+  reviewerUpdates: FormattedReviewerUpdateInfo[],
+  commentThreads: CommentThread[]
+): CombinedMessage[] {
+  let mi = 0;
+  let ri = 0;
+  let combinedMessages: CombinedMessage[] = [];
+  let mDate;
+  let rDate;
+  for (let i = 0; i < messages.length; i++) {
+    // TODO(TS): clone message instead and avoid API object mutation
+    (messages[i] as CombinedMessage)._index = i;
+  }
+
+  while (mi < messages.length || ri < reviewerUpdates.length) {
+    if (mi >= messages.length) {
+      combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+      break;
+    }
+    if (ri >= reviewerUpdates.length) {
+      combinedMessages = combinedMessages.concat(messages.slice(mi));
+      break;
+    }
+    mDate = mDate || parseDate(messages[mi].date);
+    rDate = rDate || parseDate(reviewerUpdates[ri].date);
+    if (rDate < mDate) {
+      combinedMessages.push(reviewerUpdates[ri++]);
+      rDate = null;
+    } else {
+      combinedMessages.push(messages[mi++]);
+      mDate = null;
+    }
+  }
+
+  for (let i = 0; i < combinedMessages.length; i++) {
+    const message = combinedMessages[i];
+    if (message.expanded === undefined) {
+      message.expanded = false;
+    }
+    message.commentThreads = computeThreads(message, commentThreads);
+    message._revision_number = computeRevision(message, combinedMessages);
+    message.tag = computeTag(message);
+  }
+  // computeIsImportant() depends on tags and revision numbers already being
+  // updated for all messages, so we have to compute this in its own forEach
+  // loop.
+  combinedMessages.forEach(m => {
+    m.isImportant = computeIsImportant(m, combinedMessages);
+  });
+  return combinedMessages;
+}
+
+/**
  * Unimportant messages are initially hidden.
  *
  * Human messages are always important. They have an undefined tag.
@@ -194,69 +247,81 @@
   computeIsImportant,
 };
 
-export interface GrMessagesList {
-  $: {
-    messageRepeat: DomRepeat;
-  };
-}
-
 @customElement('gr-messages-list')
-export class GrMessagesList extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMessagesList extends LitElement {
+  // TODO: Evaluate if we still need to have display: flex on the :host and
+  // .header.
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      css`
+        :host {
+          display: flex;
+          justify-content: space-between;
+        }
+        .header {
+          align-items: center;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .highlighted {
+          animation: 3s fadeOut;
+        }
+        @keyframes fadeOut {
+          0% {
+            background-color: var(--emphasis-color);
+          }
+          100% {
+            background-color: var(--view-background-color);
+          }
+        }
+        .container {
+          align-items: center;
+          display: flex;
+        }
+        .hiddenEntries {
+          color: var(--deemphasized-text-color);
+        }
+        gr-message:not(:last-of-type) {
+          border-bottom: 1px solid var(--border-color);
+        }
+      `,
+    ];
   }
 
-  // Private internal @state, derived from the application state.
-  @property({type: Object})
-  change?: ParsedChangeInfo;
-
-  // Private internal @state, derived from the application state.
-  @property({type: String})
-  changeNum?: ChangeId | NumericChangeId;
-
   @property({type: Array})
   messages: ChangeMessageInfo[] = [];
 
   @property({type: Array})
-  reviewerUpdates: ReviewerUpdateInfo[] = [];
-
-  // Private internal @state, derived from the application state.
-  @property({type: Object})
-  commentThreads: CommentThread[] = [];
-
-  // Private internal @state, derived from the application state.
-  @property({type: String})
-  projectName?: RepoName;
-
-  // Private internal @state, derived from the application state.
-  @property({type: Boolean})
-  showReplyButtons = false;
+  reviewerUpdates: FormattedReviewerUpdateInfo[] = [];
 
   @property({type: Object})
   labels?: LabelNameToInfoMap;
 
-  @property({type: String})
-  _expandAllState = ExpandAllState.EXPAND_ALL;
+  @state()
+  private change?: ParsedChangeInfo;
 
-  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
-  _expandAllTitle = '';
+  @state()
+  private changeNum?: ChangeId | NumericChangeId;
 
-  @property({type: Boolean, observer: '_observeShowAllActivity'})
-  _showAllActivity = false;
+  @state()
+  private commentThreads: CommentThread[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeCombinedMessages(messages, reviewerUpdates, ' +
-      'commentThreads)',
-    observer: '_combinedMessagesChanged',
-  })
-  _combinedMessages: CombinedMessage[] = [];
+  @state()
+  private projectName?: RepoName;
 
-  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: LabelExtreme = {};
+  @state()
+  expandAllState = ExpandAllState.EXPAND_ALL;
 
-  private readonly userModel = getAppContext().userModel;
+  // Private but used in tests.
+  @state()
+  showAllActivity = false;
+
+  @state()
+  private combinedMessages: CombinedMessage[] = [];
 
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -267,43 +332,106 @@
 
   private readonly shortcuts = getAppContext().shortcutsService;
 
-  private subscriptions: Subscription[] = [];
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.subscriptions.push(
-      this.getCommentsModel().threads$.subscribe(x => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      x => {
         this.commentThreads = x;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.changeModel().change$.subscribe(x => {
+    subscribe(
+      this,
+      () => this.changeModel().change$,
+      x => {
         this.change = x;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.userModel.loggedIn$.subscribe(x => {
-        this.showReplyButtons = x;
-      })
-    );
-    this.subscriptions.push(
-      this.changeModel().repo$.subscribe(x => {
+    subscribe(
+      this,
+      () => this.changeModel().repo$,
+      x => {
         this.projectName = x;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.changeModel().changeNum$.subscribe(x => {
+    subscribe(
+      this,
+      () => this.changeModel().changeNum$,
+      x => {
         this.changeNum = x;
-      })
+      }
     );
   }
 
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('messages') ||
+      changedProperties.has('reviewerUpdates') ||
+      changedProperties.has('commentThreads')
+    ) {
+      this.combinedMessages = computeCombinedMessages(
+        this.messages,
+        this.reviewerUpdates,
+        this.commentThreads
+      );
+      this.combinedMessagesChanged();
     }
-    this.subscriptions = [];
-    super.disconnectedCallback();
+  }
+
+  override render() {
+    const labelExtremes = this.computeLabelExtremes();
+    return html`${this.renderHeader()}
+    ${this.combinedMessages
+      .filter(m => this.showAllActivity || m.isImportant)
+      .map(
+        message => html`<gr-message
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .message=${message}
+          .commentThreads=${message.commentThreads}
+          .projectName=${this.projectName}
+          @message-anchor-tap=${this.handleAnchorClick}
+          .labelExtremes=${labelExtremes}
+          data-message-id=${ifDefined(getMessageId(message) as String)}
+        ></gr-message>`
+      )}`;
+  }
+
+  private renderHeader() {
+    return html`<div class="header">
+      <div id="showAllActivityToggleContainer" class="container">
+        ${when(
+          this.combinedMessages.some(m => !m.isImportant),
+          () => html`
+            <paper-toggle-button
+              class="showAllActivityToggle"
+              ?checked=${this.showAllActivity}
+              @change=${this.handleShowAllActivityChanged}
+              aria-labelledby="showAllEntriesLabel"
+              role="switch"
+              @click=${this.onTapShowAllActivityToggle}
+            ></paper-toggle-button>
+            <div id="showAllEntriesLabel" aria-hidden="true">
+              <span>Show all entries</span>
+              <span class="hiddenEntries" ?hidden=${this.showAllActivity}>
+                (${this.combinedMessages.filter(m => !m.isImportant).length}
+                hidden)
+              </span>
+            </div>
+            <span class="transparent separator"></span>
+          `
+        )}
+      </div>
+      <gr-button
+        id="collapse-messages"
+        link
+        .title=${this.computeExpandAllTitle()}
+        @click=${this.handleExpandCollapseTap}
+      >
+        ${this.expandAllState}
+      </gr-button>
+    </div>`;
   }
 
   async scrollToMessage(messageID: string) {
@@ -312,14 +440,14 @@
       | GrMessage
       | undefined;
 
-    if (!el && this._showAllActivity) {
+    if (!el && this.showAllActivity) {
       this.reporting.error(
         new Error(`Failed to scroll to message: ${messageID}`)
       );
       return;
     }
     if (!el || !el.message) {
-      this._showAllActivity = true;
+      this.showAllActivity = true;
       setTimeout(() => this.scrollToMessage(messageID));
       return;
     }
@@ -336,101 +464,27 @@
       top += offsetParent.offsetTop;
     }
     window.scrollTo(0, top);
-    this._highlightEl(el);
+    this.highlightEl(el);
   }
 
-  _observeShowAllActivity() {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
+  private handleShowAllActivityChanged(e: Event) {
+    this.showAllActivity = (e.target as HTMLInputElement).checked ?? false;
   }
 
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message: CombinedMessage) {
-    return this._showAllActivity || message.isImportant;
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array. Also processes
-   * all messages and updates, aligns or massages some of the properties.
-   */
-  _computeCombinedMessages(
-    messages: ChangeMessageInfo[] | undefined,
-    reviewerUpdates: FormattedReviewerUpdateInfo[] | undefined,
-    commentThreads: CommentThread[]
-  ) {
-    if (messages === undefined || reviewerUpdates === undefined) return;
-
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages: CombinedMessage[] = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      // TODO(TS): clone message instead and avoid API object mutation
-      (messages[i] as CombinedMessage)._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || parseDate(messages[mi].date);
-      rDate = rDate || parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-
-    for (let i = 0; i < combinedMessages.length; i++) {
-      const message = combinedMessages[i];
-      if (message.expanded === undefined) {
-        message.expanded = false;
-      }
-      message.commentThreads = computeThreads(message, commentThreads);
-      message._revision_number = computeRevision(message, combinedMessages);
-      message.tag = computeTag(message);
-    }
-    // computeIsImportant() depends on tags and revision numbers already being
-    // updated for all messages, so we have to compute this in its own forEach
-    // loop.
-    combinedMessages.forEach(m => {
-      m.isImportant = computeIsImportant(m, combinedMessages);
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp: boolean) {
-    if (!this._combinedMessages) return;
-
-    for (let i = 0; i < this._combinedMessages.length; i++) {
-      this._combinedMessages[i].expanded = exp;
-      this.notifyPath(`_combinedMessages.${i}.expanded`);
-    }
+  private refreshMessages() {
     for (const message of queryAll<GrMessage>(this, 'gr-message')) {
-      message.requestUpdate('message');
+      message.requestUpdate();
     }
   }
 
-  _computeExpandAllTitle(_expandAllState?: string) {
-    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+  private computeExpandAllTitle() {
+    if (this.expandAllState === ExpandAllState.COLLAPSE_ALL) {
       return this.shortcuts.createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+    if (this.expandAllState === ExpandAllState.EXPAND_ALL) {
       return this.shortcuts.createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
@@ -439,8 +493,10 @@
     return '';
   }
 
-  _highlightEl(el: HTMLElement) {
-    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+  // Private but used in tests.
+  highlightEl(el: HTMLElement) {
+    const highlightedEls =
+      this.shadowRoot?.querySelectorAll('.highlighted') ?? [];
     for (const highlightedEl of highlightedEls) {
       highlightedEl.classList.remove('highlighted');
     }
@@ -452,42 +508,36 @@
     el.classList.add('highlighted');
   }
 
+  // Private but used in tests.
   handleExpandCollapse(expand: boolean) {
-    this._expandAllState = expand
+    this.expandAllState = expand
       ? ExpandAllState.COLLAPSE_ALL
       : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
+    if (!this.combinedMessages) return;
+    for (let i = 0; i < this.combinedMessages.length; i++) {
+      this.combinedMessages[i].expanded = expand;
+    }
+    this.refreshMessages();
   }
 
-  _handleExpandCollapseTap(e: Event) {
+  private handleExpandCollapseTap(e: Event) {
     e.preventDefault();
     this.handleExpandCollapse(
-      this._expandAllState === ExpandAllState.EXPAND_ALL
+      this.expandAllState === ExpandAllState.EXPAND_ALL
     );
   }
 
-  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+  private handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
     this.scrollToMessage(e.detail.id);
   }
 
-  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
-    return messages.some(m => !m.isImportant);
-  }
-
-  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
-    return messages.filter(m => !m.isImportant).length;
-  }
-
   /**
-   * Called when this._combinedMessages has changed.
+   * Called when this.combinedMessages has changed.
    */
-  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
-    if (!combinedMessages) return;
-    if (combinedMessages.length === 0) return;
-    for (let i = 0; i < combinedMessages.length; i++) {
-      this.notifyPath(`_combinedMessages.${i}.commentThreads`);
-    }
-    const tags = combinedMessages.map(
+  private combinedMessagesChanged() {
+    if (this.combinedMessages.length === 0) return;
+    this.refreshMessages();
+    const tags = this.combinedMessages.map(
       message =>
         message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
     );
@@ -496,7 +546,7 @@
         acc[val] = (acc[val] || 0) + 1;
         return acc;
       },
-      {all: combinedMessages.length} as TagsCountReportInfo
+      {all: this.combinedMessages.length} as TagsCountReportInfo
     );
     this.reporting.reportInteraction('messages-count', tagsCounted);
   }
@@ -504,20 +554,15 @@
   /**
    * Compute a mapping from label name to objects representing the minimum and
    * maximum possible values for that label.
+   * Private but used in tests.
    */
-  _computeLabelExtremes(
-    labelRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
+  computeLabelExtremes() {
     const extremes: {[labelName: string]: VotingRangeInfo} = {};
-    const labels = labelRecord.base;
-    if (!labels) {
+    if (!this.labels) {
       return extremes;
     }
-    for (const key of Object.keys(labels)) {
-      const range = getVotingRange(labels[key]);
+    for (const key of Object.keys(this.labels)) {
+      const range = getVotingRange(this.labels[key]);
       if (range) {
         extremes[key] = range;
       }
@@ -528,7 +573,7 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapShowAllActivityToggle(e: Event) {
+  private onTapShowAllActivityToggle(e: Event) {
     e.preventDefault();
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
deleted file mode 100644
index 087ee19..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      justify-content: space-between;
-    }
-    .header {
-      align-items: center;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .highlighted {
-      animation: 3s fadeOut;
-    }
-    @keyframes fadeOut {
-      0% {
-        background-color: var(--emphasis-color);
-      }
-      100% {
-        background-color: var(--view-background-color);
-      }
-    }
-    .container {
-      align-items: center;
-      display: flex;
-    }
-    .hiddenEntries {
-      color: var(--deemphasized-text-color);
-    }
-    gr-message:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="header">
-    <div id="showAllActivityToggleContainer" class="container">
-      <template
-        is="dom-if"
-        if="[[_isVisibleShowAllActivityToggle(_combinedMessages)]]"
-      >
-        <paper-toggle-button
-          class="showAllActivityToggle"
-          checked="{{_showAllActivity}}"
-          aria-labelledby="showAllEntriesLabel"
-          role="switch"
-          on-click="_onTapShowAllActivityToggle"
-        ></paper-toggle-button>
-        <div id="showAllEntriesLabel" aria-hidden="true">
-          <span>Show all entries</span>
-          <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
-            ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
-          </span>
-        </div>
-        <span class="transparent separator"></span>
-      </template>
-    </div>
-    <gr-button
-      id="collapse-messages"
-      link=""
-      title="[[_expandAllTitle]]"
-      on-click="_handleExpandCollapseTap"
-    >
-      [[_expandAllState]]
-    </gr-button>
-  </div>
-  <template
-    id="messageRepeat"
-    is="dom-repeat"
-    items="[[_combinedMessages]]"
-    as="message"
-    filter="_isMessageVisible"
-  >
-    <gr-message
-      change="[[change]]"
-      change-num="[[changeNum]]"
-      message="[[message]]"
-      comment-threads="[[message.commentThreads]]"
-      project-name="[[projectName]]"
-      show-reply-button="[[showReplyButtons]]"
-      on-message-anchor-tap="_handleAnchorClick"
-      label-extremes="[[_labelExtremes]]"
-      data-message-id$="[[message.id]]"
-    ></gr-message>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index b9cb616..8802ff3 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -17,10 +17,8 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-messages-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
 import {MessageTag} from '../../../constants/constants';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   query,
   queryAll,
@@ -42,17 +40,8 @@
 } from '../../../types/common';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {assertIsDefined} from '../../../utils/common-util';
-
-createCommentApiMockWithTemplateElement(
-  'gr-messages-list-comment-mock-api',
-  html` <gr-messages-list id="messagesList"></gr-messages-list> `
-);
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-messages-list-comment-mock-api>
-    <gr-messages-list></gr-messages-list>
-  </gr-messages-list-comment-mock-api>
-`);
+import {html} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -99,8 +88,6 @@
   let element: GrMessagesList;
   let messages: ChangeMessageInfo[];
 
-  let commentApiWrapper: any;
-
   const getMessages = function () {
     return queryAll<GrMessage>(element, 'gr-message');
   };
@@ -156,16 +143,12 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
+      element = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
       );
       await element.getCommentsModel().reloadComments(0 as NumericChangeId);
       element.messages = messages;
-      await flush();
+      await element.updateComplete;
     });
 
     test('expand/collapse all', async () => {
@@ -176,15 +159,18 @@
         await message.updateComplete;
       }
       MockInteractions.tap(allMessageEls[1]);
+      await element.updateComplete;
       assert.isTrue(allMessageEls[1].message?.expanded);
 
       MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      await element.updateComplete;
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isTrue(message.message?.expanded);
       }
 
       MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      await element.updateComplete;
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isFalse(message.message?.expanded);
@@ -230,7 +216,7 @@
       }
 
       const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
+      const highlightStub = sinon.stub(element, 'highlightEl');
 
       await element.scrollToMessage('invalid');
 
@@ -255,7 +241,7 @@
 
     test('scroll to message offscreen', async () => {
       const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
+      const highlightStub = sinon.stub(element, 'highlightEl');
       element.messages = generateRandomMessages(25);
       await element.updateComplete;
       assert.isFalse(scrollToStub.called);
@@ -271,7 +257,7 @@
       );
     });
 
-    test('associating messages with comments', () => {
+    test('associating messages with comments', async () => {
       // Have to type as any otherwise fails with
       // Argument of type 'ChangeMessageInfo[]' is not assignable to
       // parameter of type 'ConcatArray<never>'.
@@ -295,14 +281,14 @@
         } as CombinedMessage
       );
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageElements = getMessages();
       assert.equal(messageElements.length, messages.length);
       assert.deepEqual(messageElements[1].message, messages[1]);
       assert.deepEqual(messageElements[2].message, messages[2]);
     });
 
-    test('threads', () => {
+    test('threads', async () => {
       const messages = [
         {
           _index: 5,
@@ -314,7 +300,7 @@
         },
       ];
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageElements = getMessages();
       // threads
       assert.equal(messageElements[0].message!.commentThreads.length, 3);
@@ -468,7 +454,7 @@
       assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
     });
 
-    test('isImportant is evaluated after tag update', () => {
+    test('isImportant is evaluated after tag update', async () => {
       const m1 = randomMessage({
         ...randomMessage(),
         tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
@@ -480,12 +466,12 @@
         _revision_number: 2 as PatchSetNum,
       });
       element.messages = [m1, m2];
-      flush();
+      await element.updateComplete;
       assert.isFalse((m1 as CombinedMessage).isImportant);
       assert.isTrue((m2 as CombinedMessage).isImportant);
     });
 
-    test('messages without author do not throw', () => {
+    test('messages without author do not throw', async () => {
       const messages = [
         {
           _index: 5,
@@ -496,7 +482,7 @@
         },
       ];
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
       assert.equal(messageEls[0].message!.message, messages[0].message);
@@ -507,9 +493,7 @@
     let element: GrMessagesList;
     let messages: ChangeMessageInfo[];
 
-    let commentApiWrapper: any;
-
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -529,15 +513,11 @@
         }),
       ];
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
+      element = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
       );
       element.messages = messages;
-      flush();
+      await element.updateComplete;
     });
 
     test('hide autogenerated button is not hidden', () => {
@@ -550,59 +530,53 @@
       assert.equal(displayedMsgs.length, 2);
     });
 
-    test('unimportant messages hidden after toggle', () => {
-      element._showAllActivity = true;
+    test('unimportant messages hidden after toggle', async () => {
+      element.showAllActivity = true;
+      await element.updateComplete;
       const toggle = queryAndAssert(element, '.showAllActivityToggle');
       assert.isOk(toggle);
       MockInteractions.tap(toggle);
-      flush();
+      await element.updateComplete;
       const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
       assert.equal(displayedMsgs.length, 2);
     });
 
-    test('unimportant messages shown after toggle', () => {
-      element._showAllActivity = false;
+    test('unimportant messages shown after toggle', async () => {
+      element.showAllActivity = false;
+      await element.updateComplete;
       const toggle = queryAndAssert(element, '.showAllActivityToggle');
       assert.isOk(toggle);
       MockInteractions.tap(toggle);
-      flush();
+      await element.updateComplete;
       const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
       assert.equal(displayedMsgs.length, 3);
     });
 
     test('_computeLabelExtremes', () => {
-      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
-
       // Have to type as any to be able to use null.
       element.labels = null as any;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {
         'my-label': {values: {'-12': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -12, max: -12},
       });
 
       element.labels = {
         'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -2, max: 2},
       });
 
@@ -610,8 +584,7 @@
         'my-label': {values: {'-12': {}}},
         'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -12, max: -12},
         'other-label': {min: -1, max: 1},
       });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 1f57837..6a9bd92 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -261,7 +261,7 @@
   account?: AccountInfo;
 
   @state()
-  ccs: (AccountInfoInput | GroupInfoInput)[] = [];
+  ccs: AccountInput[] = [];
 
   @state()
   attentionCcsCount = 0;
@@ -1527,8 +1527,8 @@
     if (!this.change?.owner || !this.change?.reviewers) return;
     this.owner = this.change.owner;
 
-    const reviewers = [];
-    const ccs = [];
+    const reviewers: AccountInput[] = [];
+    const ccs: AccountInput[] = [];
 
     if (this.change.reviewers) {
       for (const key of Object.keys(this.change.reviewers)) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index c800c87..8d4062e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -193,6 +193,157 @@
     return promise;
   }
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div tabindex="-1">
+        <section class="peopleContainer">
+          <gr-endpoint-decorator name="reply-reviewers">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+            <div class="peopleList">
+              <div class="peopleListLabel">Reviewers</div>
+              <gr-account-list id="reviewers"> </gr-account-list>
+              <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+            </div>
+            <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+          </gr-endpoint-decorator>
+          <div class="peopleList">
+            <div class="peopleListLabel">CC</div>
+            <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+          </div>
+          <gr-overlay
+            aria-hidden="true"
+            id="reviewerConfirmationOverlay"
+            style="outline: none; display: none;"
+          >
+            <div class="reviewerConfirmation">
+              Group
+              <span class="groupName"> </span>
+              has
+              <span class="groupSize"> </span>
+              members.
+              <br />
+              Are you sure you want to add them all?
+            </div>
+            <div class="reviewerConfirmationButtons">
+              <gr-button aria-disabled="false" role="button" tabindex="0">
+                Yes
+              </gr-button>
+              <gr-button aria-disabled="false" role="button" tabindex="0">
+                No
+              </gr-button>
+            </div>
+          </gr-overlay>
+        </section>
+        <section class="labelsContainer">
+          <gr-endpoint-decorator name="reply-label-scores">
+            <gr-label-scores id="labelScores"> </gr-label-scores>
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <div id="pluginMessage"></div>
+        </section>
+        <section class="newReplyDialog textareaContainer">
+          <div class="patchsetLevelContainer resolved">
+            <gr-endpoint-decorator name="reply-text">
+              <gr-textarea
+                class="message monospace newReplyDialog"
+                id="textarea"
+                monospace=""
+              >
+              </gr-textarea>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+            <div class="labelContainer">
+              <label>
+                <input
+                  checked=""
+                  id="resolvedPatchsetLevelCommentCheckbox"
+                  type="checkbox"
+                />
+                Resolved
+              </label>
+              <label class="preview-formatting">
+                <input type="checkbox" />
+                Preview formatting
+              </label>
+            </div>
+          </div>
+        </section>
+        <div class="newReplyDialog stickyBottom">
+          <gr-endpoint-decorator name="reply-bottom">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <section class="attention">
+              <div class="attentionSummary">
+                <div>
+                  <span> No changes to the attention set. </span>
+                  <gr-tooltip-content
+                    has-tooltip=""
+                    title="Edit attention set changes"
+                  >
+                    <gr-button
+                      aria-disabled="false"
+                      class="edit-attention-button"
+                      data-action-key="edit"
+                      data-action-type="change"
+                      data-label="Edit"
+                      link=""
+                      position-below=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      <iron-icon icon="gr-icons:edit"> </iron-icon>
+                      Modify
+                    </gr-button>
+                  </gr-tooltip-content>
+                </div>
+                <div>
+                  <a
+                    href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+                    target="_blank"
+                  >
+                    <iron-icon
+                      icon="gr-icons:help-outline"
+                      title="read documentation"
+                    >
+                    </iron-icon>
+                  </a>
+                </div>
+              </div>
+            </section>
+            <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+            <section class="actions">
+              <div class="left"></div>
+              <div class="right">
+                <gr-button
+                  aria-disabled="false"
+                  class="action cancel"
+                  id="cancelButton"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Cancel
+                </gr-button>
+                <gr-tooltip-content has-tooltip="" title="Send reply">
+                  <gr-button
+                    aria-disabled="false"
+                    class="action send"
+                    id="sendButton"
+                    primary=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Send
+                  </gr-button>
+                </gr-tooltip-content>
+              </div>
+            </section>
+          </gr-endpoint-decorator>
+        </div>
+      </div>
+    `);
+  });
+
   test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 6740977..d5ce654 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -29,12 +29,7 @@
   LabelInfo,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
@@ -68,8 +63,6 @@
 
   @state() showAllReviewers = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       sharedStyles,
@@ -166,14 +159,12 @@
         .vote=${this.computeVote(reviewer)}
         .label=${this.computeCodeReviewLabel()}
       >
-        ${showNewSubmitRequirements(this.flagsService, this.change)
-          ? html`<gr-vote-chip
-              slot="vote-chip"
-              .vote=${this.computeVote(reviewer)}
-              .label=${this.computeCodeReviewLabel()}
-              circle-shape
-            ></gr-vote-chip>`
-          : nothing}
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
       </gr-account-chip>
     `;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index eb6d071..9e4bf3f 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
@@ -116,7 +116,8 @@
   return !!(diff.binary && (isA || isB));
 }
 
-interface LineInfo {
+// visible for testing
+export interface LineInfo {
   beforeNumber?: LineNumber;
   afterNumber?: LineNumber;
 }
@@ -277,15 +278,18 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly reporting = getAppContext().reportingService;
+  // visible for testing
+  readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly jsAPI = getAppContext().jsApiService;
+  // visible for testing
+  readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly syntaxLayer: GrSyntaxLayerWorker;
+  // visible for testing
+  readonly syntaxLayer: GrSyntaxLayerWorker;
 
   private checksSubscription?: Subscription;
 
@@ -315,13 +319,6 @@
     );
   }
 
-  override ready() {
-    super.ready();
-    if (this._canReload()) {
-      this.reload();
-    }
-  }
-
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
@@ -390,7 +387,6 @@
       // assets in parallel.
       const layerPromise = this.initLayers();
       const diff = await this._getDiff();
-      this.subscribeToChecks();
       this._loadedWhitespaceLevel = whitespaceLevel;
       this._reportDiff(diff);
 
@@ -408,8 +404,10 @@
       this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails());
 
       this.reporting.time(Timing.DIFF_CONTENT);
+
       const syntaxLayerPromise = this.syntaxLayer.process(diff);
       await waitForEventOnce(this, 'render');
+      this.subscribeToChecks();
       this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails());
 
       if (shouldReportMetric) {
@@ -752,12 +750,6 @@
     return this.restApiService.getLoggedIn();
   }
 
-  _canReload() {
-    return (
-      !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
-    );
-  }
-
   // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
   prefetchDiff() {
     if (
@@ -1162,7 +1154,9 @@
     if (prefsChangeRecord === undefined) return;
     if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
 
-    if (!noRenderOnPrefsChange) this.reload();
+    if (!noRenderOnPrefsChange) {
+      this.reload();
+    }
   }
 
   _computeParentIndex(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
deleted file mode 100644
index dda8490..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ /dev/null
@@ -1,1587 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-host.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
-import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
-import {addListenerForTest, mockPromise, stubRestApi, waitUntil} from '../../../test/test-utils.js';
-import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
-import {CoverageType} from '../../../types/types.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image.js';
-import {waitForEventOnce} from '../../../utils/event-util.js';
-
-const basicFixture = fixtureFromElement('gr-diff-host');
-
-suite('gr-diff-host tests', () => {
-  let element;
-
-  let loggedIn;
-
-  setup(async () => {
-    loggedIn = false;
-    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.path = 'some/path';
-    sinon.stub(element.reporting, 'time');
-    sinon.stub(element.reporting, 'timeEnd');
-    await flush();
-  });
-
-  suite('plugin layers', () => {
-    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
-      element.changeNum = 123;
-      element.path = 'some/path';
-    });
-    test('plugin layers requested', async () => {
-      element.patchRange = {};
-      element.change = createChange();
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert(element.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('render reporting', () => {
-    test('ends total and syntax timer after syntax layer', async () => {
-      sinon.stub(element.reporting, 'diffViewContentDisplayed');
-      let notifySyntaxProcessed;
-      sinon.stub(element.syntaxLayer, 'process').returns(
-          new Promise(resolve => {
-            notifySyntaxProcessed = resolve;
-          })
-      );
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = createDefaultDiffPrefs();
-      element.reload(true);
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      notifySyntaxProcessed();
-      await waitUntil(() => element.reporting.timeEnd.callCount === 4);
-      const calls = element.reporting.timeEnd.getCalls();
-      assert.equal(calls.length, 4);
-      assert.equal(calls[0].args[0], 'Diff Load Render');
-      assert.equal(calls[1].args[0], 'Diff Content Render');
-      assert.equal(calls[2].args[0], 'Diff Syntax Render');
-      assert.equal(calls[3].args[0], 'Diff Total Render');
-      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
-    });
-
-    test('completes reload promise after syntax layer processing', async () => {
-      let notifySyntaxProcessed;
-      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
-            notifySyntaxProcessed = resolve;
-          }));
-      stubRestApi('getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      let reloadComplete = false;
-      element.prefs = createDefaultDiffPrefs();
-      element.reload().then(() => {
-        reloadComplete = true;
-      });
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      assert.isFalse(reloadComplete);
-      notifySyntaxProcessed();
-      await waitUntil(() => reloadComplete);
-      assert.isTrue(reloadComplete);
-    });
-  });
-
-  test('reload() cancels before network resolves', () => {
-    const cancelStub = sinon.stub(element.$.diff, 'cancel');
-
-    // Stub the network calls into requests that never resolve.
-    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
-    element.patchRange = {};
-    element.change = createChange();
-
-    // Needs to be set to something first for it to cancel.
-    element.diff = {
-      content: [{
-        a: ['foo'],
-      }],
-    };
-
-    element.reload();
-    assert.isTrue(cancelStub.called);
-  });
-
-  test('reload() loads files weblinks', async () => {
-    element.change = createChange();
-    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-        .returns({name: 'stubb', url: '#s'});
-    stubRestApi('getDiff').returns(Promise.resolve({
-      content: [],
-    }));
-    element.projectName = 'test-project';
-    element.path = 'test-path';
-    element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-    element.patchRange = {};
-
-    await element.reload();
-
-    assert.equal(weblinksStub.callCount, 3);
-    assert.deepEqual(weblinksStub.firstCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.EDIT});
-    assert.deepEqual(element.editWeblinks, [{
-      name: 'stubb', url: '#s',
-    }]);
-    assert.deepEqual(weblinksStub.secondCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(weblinksStub.thirdCall.args[0], {
-      commit: 'test-commit',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(element.filesWeblinks, {
-      meta_a: [{name: 'stubb', url: '#s'}],
-      meta_b: [{name: 'stubb', url: '#s'}],
-    });
-  });
-
-  test('prefetch getDiff', async () => {
-    const diffRestApiStub = stubRestApi('getDiff')
-        .returns(Promise.resolve({content: []}));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    element.prefetchDiff();
-    await element._getDiff();
-    assert.isTrue(diffRestApiStub.calledOnce);
-  });
-
-  test('_getDiff handles null diff responses', async () => {
-    stubRestApi('getDiff').returns(Promise.resolve(null));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    await element._getDiff();
-  });
-
-  test('reload resolves on error', () => {
-    const onErrStub = sinon.stub(element, '_handleGetDiffError');
-    const error = new Response(null, {ok: false, status: 500});
-    stubRestApi('getDiff').callsFake(
-        (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
-          onErr(error);
-        });
-    element.patchRange = {};
-    return element.reload().then(() => {
-      assert.isTrue(onErrStub.calledOnce);
-    });
-  });
-
-  suite('_handleGetDiffError', () => {
-    let serverErrorStub;
-    let pageErrorStub;
-
-    setup(() => {
-      serverErrorStub = sinon.stub();
-      addListenerForTest(document, 'server-error', serverErrorStub);
-      pageErrorStub = sinon.stub();
-      addListenerForTest(document, 'page-error', pageErrorStub);
-    });
-
-    test('page error on HTTP-409', () => {
-      element._handleGetDiffError({status: 409});
-      assert.isTrue(serverErrorStub.calledOnce);
-      assert.isFalse(pageErrorStub.called);
-      assert.isNotOk(element._errorMessage);
-    });
-
-    test('server error on non-HTTP-409', () => {
-      element._handleGetDiffError({
-        status: 500,
-        text: () => Promise.resolve(''),
-      });
-      assert.isFalse(serverErrorStub.called);
-      assert.isTrue(pageErrorStub.calledOnce);
-      assert.isNotOk(element._errorMessage);
-    });
-
-    test('error message if showLoadFailure', () => {
-      element.showLoadFailure = true;
-      element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-      assert.isFalse(serverErrorStub.called);
-      assert.isFalse(pageErrorStub.called);
-      assert.equal(element._errorMessage,
-          'Encountered error when loading the diff: 500 Failure!');
-    });
-  });
-
-  suite('image diffs', () => {
-    let mockFile1;
-    let mockFile2;
-    setup(() => {
-      mockFile1 = {
-        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-        'wsAAAAAAAAAAAAAAAAA/w==',
-        type: 'image/bmp',
-      };
-      mockFile2 = {
-        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-        'wsAAAAAAAAAAAAA/////w==',
-        type: 'image/bmp',
-      };
-
-      element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-      element.change = createChange();
-      element.comments = {
-        left: [],
-        right: [],
-        meta: {patchRange: element.patchRange},
-      };
-    });
-
-    test('renders image diffs with same file name', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index 2adc47d..f9c2f2c 100644',
-          '--- a/carrot.jpg',
-          '+++ b/carrot.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-      }));
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await waitForEventOnce(element, 'render');
-
-      // Recognizes that it should be an image diff.
-      assert.isTrue(element.isImageDiff);
-      assert.instanceOf(
-          element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-      // Left image rendered with the parent commit's version of the file.
-      const leftImage =
-          element.$.diff.$.diffTable.querySelector('td.left img');
-      const leftLabel =
-          element.$.diff.$.diffTable.querySelector('td.left label');
-      const leftLabelContent = leftLabel.querySelector('.label');
-      const leftLabelName = leftLabel.querySelector('.name');
-
-      const rightImage =
-          element.$.diff.$.diffTable.querySelector('td.right img');
-      const rightLabel = element.$.diff.$.diffTable.querySelector(
-          'td.right label');
-      const rightLabelContent = rightLabel.querySelector('.label');
-      const rightLabelName = rightLabel.querySelector('.name');
-
-      assert.isOk(leftImage);
-      assert.equal(leftImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile1.body);
-      assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-      assert.isNotOk(leftLabelName);
-
-      assert.isOk(rightImage);
-      assert.equal(rightImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile2.body);
-      assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-      assert.isNotOk(rightLabelName);
-    });
-
-    test('renders image diffs with a different file name', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot2.jpg',
-          'index 2adc47d..f9c2f2c 100644',
-          '--- a/carrot.jpg',
-          '+++ b/carrot2.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot2.jpg',
-        },
-      }));
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await waitForEventOnce(element, 'render');
-
-      // Recognizes that it should be an image diff.
-      assert.isTrue(element.isImageDiff);
-      assert.instanceOf(
-          element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-      // Left image rendered with the parent commit's version of the file.
-      const leftImage =
-          element.$.diff.$.diffTable.querySelector('td.left img');
-      const leftLabel =
-          element.$.diff.$.diffTable.querySelector('td.left label');
-      const leftLabelContent = leftLabel.querySelector('.label');
-      const leftLabelName = leftLabel.querySelector('.name');
-
-      const rightImage =
-          element.$.diff.$.diffTable.querySelector('td.right img');
-      const rightLabel = element.$.diff.$.diffTable.querySelector(
-          'td.right label');
-      const rightLabelContent = rightLabel.querySelector('.label');
-      const rightLabelName = rightLabel.querySelector('.name');
-
-      assert.isOk(rightLabelName);
-      assert.isOk(leftLabelName);
-      assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-      assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-      assert.isOk(leftImage);
-      assert.equal(leftImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile1.body);
-      assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-
-      assert.isOk(rightImage);
-      assert.equal(rightImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile2.body);
-      assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-    });
-
-    test('renders added image', async () => {
-      const mockDiff = {
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'ADDED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index 0000000..f9c2f2c 100644',
-          '--- /dev/null',
-          '+++ b/carrot.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: null,
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot2.jpg',
-        },
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders removed image', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'DELETED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index f9c2f2c..0000000 100644',
-          '--- a/carrot.jpg',
-          '+++ /dev/null',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: null,
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('does not render disallowed image type', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'DELETED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index f9c2f2c..0000000 100644',
-          '--- a/carrot.jpg',
-          '+++ /dev/null',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      mockFile1.type = 'image/jpeg-evil';
-
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: null,
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-  });
-
-  test('cannot create comments when not logged in', () => {
-    element.patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    const showAuthRequireSpy = sinon.spy();
-    element.addEventListener('show-auth-required', showAuthRequireSpy);
-
-    element.dispatchEvent(new CustomEvent('create-comment', {
-      detail: {
-        lineNum: 3,
-        side: Side.LEFT,
-        path: '/p',
-      },
-    }));
-
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
-
-    assert.equal(threads.length, 0);
-
-    assert.isTrue(showAuthRequireSpy.called);
-  });
-
-  test('delegates cancel()', () => {
-    const stub = sinon.stub(element.$.diff, 'cancel');
-    element.patchRange = {};
-    element.cancel();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates getCursorStops()', () => {
-    const returnValue = [document.createElement('b')];
-    const stub = sinon.stub(element.$.diff, 'getCursorStops')
-        .returns(returnValue);
-    assert.equal(element.getCursorStops(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates isRangeSelected()', () => {
-    const returnValue = true;
-    const stub = sinon.stub(element.$.diff, 'isRangeSelected')
-        .returns(returnValue);
-    assert.equal(element.isRangeSelected(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleLeftDiff()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
-    element.toggleLeftDiff();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  suite('blame', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.path = 'some/path';
-      await flush();
-    });
-
-    test('clearBlame', () => {
-      element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
-      element.clearBlame();
-      assert.isNull(element._blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.equal(element.isBlameLoaded, false);
-    });
-
-    test('loadBlame', () => {
-      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame().then(() => {
-        assert.isTrue(getBlameStub.calledWithExactly(
-            42, 5, 'foo/bar.baz', true));
-        assert.isFalse(showAlertStub.called);
-        assert.equal(element._blame, mockBlame);
-        assert.equal(element.isBlameLoaded, true);
-      });
-    });
-
-    test('loadBlame empty', () => {
-      const mockBlame = [];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame()
-          .then(() => {
-            assert.isTrue(false, 'Promise should not resolve');
-          })
-          .catch(() => {
-            assert.isTrue(showAlertStub.calledOnce);
-            assert.isNull(element._blame);
-            assert.equal(element.isBlameLoaded, false);
-          });
-    });
-  });
-
-  test('getThreadEls() returns .comment-threads', () => {
-    const threadEl = document.createElement('gr-comment-thread');
-    threadEl.className = 'comment-thread';
-    element.$.diff.appendChild(threadEl);
-    assert.deepEqual(element.getThreadEls(), [threadEl]);
-  });
-
-  test('delegates addDraftAtLine(el)', () => {
-    const param0 = document.createElement('b');
-    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
-    element.addDraftAtLine(param0);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 1);
-    assert.equal(stub.lastCall.args[0], param0);
-  });
-
-  test('delegates clearDiffContent()', () => {
-    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
-    element.clearDiffContent();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
-    element.toggleAllContext();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('passes in noAutoRender', () => {
-    const value = true;
-    element.noAutoRender = value;
-    assert.equal(element.$.diff.noAutoRender, value);
-  });
-
-  test('passes in path', () => {
-    const value = 'some/file/path';
-    element.path = value;
-    assert.equal(element.$.diff.path, value);
-  });
-
-  test('passes in prefs', () => {
-    const value = {};
-    element.prefs = value;
-    assert.equal(element.$.diff.prefs, value);
-  });
-
-  test('passes in displayLine', () => {
-    const value = true;
-    element.displayLine = value;
-    assert.equal(element.$.diff.displayLine, value);
-  });
-
-  test('passes in hidden', () => {
-    const value = true;
-    element.hidden = value;
-    assert.equal(element.$.diff.hidden, value);
-    assert.isNotNull(element.getAttribute('hidden'));
-  });
-
-  test('passes in noRenderOnPrefsChange', () => {
-    const value = true;
-    element.noRenderOnPrefsChange = value;
-    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-  });
-
-  test('passes in lineWrapping', () => {
-    const value = true;
-    element.lineWrapping = value;
-    assert.equal(element.$.diff.lineWrapping, value);
-  });
-
-  test('passes in viewMode', () => {
-    const value = 'SIDE_BY_SIDE';
-    element.viewMode = value;
-    assert.equal(element.$.diff.viewMode, value);
-  });
-
-  test('passes in lineOfInterest', () => {
-    const value = {lineNum: 123, side: Side.LEFT};
-    element.lineOfInterest = value;
-    assert.equal(element.$.diff.lineOfInterest, value);
-  });
-
-  suite('_reportDiff', () => {
-    let reportStub;
-
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.path = 'file.txt';
-      element.patchRange = {basePatchNum: 1};
-      reportStub = sinon.stub(element.reporting, 'reportInteraction');
-      await flush();
-    });
-
-    test('null and content-less', () => {
-      element._reportDiff(null);
-      assert.isFalse(reportStub.called);
-
-      element._reportDiff({});
-      assert.isFalse(reportStub.called);
-    });
-
-    test('diff w/ no delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {ab: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ no rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ some rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 50}
-      ));
-    });
-
-    test('diff w/ all rebase delta', () => {
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-        due_to_rebase: true,
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 100}
-      ));
-    });
-
-    test('diff against parent event', () => {
-      element.patchRange.basePatchNum = 'PARENT';
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-  });
-
-  suite('createCheckEl method', () => {
-    test('start_line:12', () => {
-      const result = {
-        codePointers: [{range: {start_line: 12}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-12');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '12');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-
-    test('start_line:13 end_line:14 without char positions', () => {
-      const result = {
-        codePointers: [{range: {start_line: 13, end_line: 14}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-14');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '14');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-
-    test('start_line:13 end_line:14 with char positions', () => {
-      const result = {
-        codePointers: [
-          {
-            range: {
-              start_line: 13,
-              end_line: 14,
-              start_character: 5,
-              end_character: 7,
-            },
-          },
-        ],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-14');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '14');
-      assert.equal(el.getAttribute('range'),
-          '{"start_line":13,' +
-          '"end_line":14,' +
-          '"start_character":5,' +
-          '"end_character":7}');
-      assert.equal(el.result, result);
-    });
-
-    test('empty range', () => {
-      const result = {
-        codePointers: [{range: {}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-FILE');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), 'FILE');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-  });
-
-  suite('create-comment', () => {
-    setup(async () => {
-      loggedIn = true;
-      element.connectedCallback();
-      await flush();
-    });
-
-    test('creates comments if they do not exist yet', () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 3,
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].thread.commentSide, 'PARENT');
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.range, undefined);
-      assert.equal(threads[0].thread.patchNum, 2);
-
-      // Try to fetch a thread with a different range.
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 3,
-      };
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 1,
-          side: Side.LEFT,
-          path: '/p',
-          range,
-        },
-      }));
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[0].thread.commentSide, 'PARENT');
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[1].thread.range, range);
-      assert.equal(threads[1].thread.patchNum, 3);
-    });
-
-    test('should not be on parent if on the right', () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'REVISION');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
-    });
-
-    test('should be on parent if right and base is PARENT', () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'PARENT');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('should be on parent if right and base negative', () => {
-      element.patchRange = {
-        basePatchNum: -2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'PARENT');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('should not be on parent otherwise', () => {
-      element.patchRange = {
-        basePatchNum: 2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'REVISION');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('thread should use old file path if first created ' +
-    'on patch set (left) before renaming', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.path, element.file.basePath);
-    });
-
-    test('thread should use new file path if first created ' +
-    'on patch set (right) after renaming', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
-      assert.equal(threads[0].thread.path, element.file.path);
-    });
-
-    test('multiple threads created on the same range', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      const comment = {
-        ...createComment(),
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 2,
-        },
-        patch_set: 3,
-      };
-      const thread = createCommentThread([comment]);
-      element.threads = [thread];
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      element.threads= [...element.threads, thread];
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // Threads have same rootId so element is reused
-      assert.equal(threads.length, 1);
-
-      const newThread = {...thread};
-      newThread.rootId = 'differentRootId';
-      element.threads= [...element.threads, newThread];
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // New thread has a different rootId
-      assert.equal(threads.length, 2);
-    });
-
-    test('unsaved thread changes to draft', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      element.threads = [];
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-          path: element.path,
-          lineNum: 13,
-        },
-      }));
-      await flush();
-      assert.equal(element.getThreadEls().length, 1);
-      const threadEl = element.getThreadEls()[0];
-      assert.equal(threadEl.thread.line, 13);
-      assert.isDefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread.comments.length, 0);
-
-      const draftThread = createCommentThread([{
-        path: element.path,
-        patch_set: 3,
-        line: 13,
-        __draft: true,
-      }]);
-      element.threads = [draftThread];
-      await flush();
-
-      // We expect that no additional thread element was created.
-      assert.equal(element.getThreadEls().length, 1);
-      // In fact the thread element must still be the same.
-      assert.equal(element.getThreadEls()[0], threadEl);
-      // But it must have been updated from unsaved to draft:
-      assert.isUndefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread.comments.length, 1);
-    });
-
-    test('thread should use new file path if first created ' +
-    'on patch set (left) but is base', async () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      const threads =
-          dom(element.$.diff).queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.path, element.file.path);
-    });
-
-    test('cannot create thread on an edit', () => {
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-
-      const diffSide = Side.LEFT;
-      element.patchRange = {
-        basePatchNum: EditPatchSetNum,
-        patchNum: 3,
-      };
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: diffSide,
-          path: '/p',
-        },
-      }));
-
-      const threads =
-          dom(element.$.diff).queryDistributedElements('gr-comment-thread');
-      assert.equal(threads.length, 0);
-      assert.isTrue(alertSpy.called);
-    });
-
-    test('cannot create thread on an edit base', () => {
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-
-      const diffSide = Side.LEFT;
-      element.patchRange = {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: EditPatchSetNum,
-      };
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: diffSide,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      assert.equal(threads.length, 0);
-      assert.isTrue(alertSpy.called);
-    });
-  });
-
-  test('_filterThreadElsForLocation with no threads', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const threads = [];
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.LEFT), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.RIGHT), []);
-  });
-
-  test('_filterThreadElsForLocation for line comments', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const l3 = document.createElement('div');
-    l3.setAttribute('line-num', 3);
-    l3.setAttribute('diff-side', Side.LEFT);
-
-    const l5 = document.createElement('div');
-    l5.setAttribute('line-num', 5);
-    l5.setAttribute('diff-side', Side.LEFT);
-
-    const r3 = document.createElement('div');
-    r3.setAttribute('line-num', 3);
-    r3.setAttribute('diff-side', Side.RIGHT);
-
-    const r5 = document.createElement('div');
-    r5.setAttribute('line-num', 5);
-    r5.setAttribute('diff-side', Side.RIGHT);
-
-    const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l3]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r5]);
-  });
-
-  test('_filterThreadElsForLocation for file comments', () => {
-    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-    const l = document.createElement('div');
-    l.setAttribute('diff-side', Side.LEFT);
-    l.setAttribute('line-num', 'FILE');
-
-    const r = document.createElement('div');
-    r.setAttribute('diff-side', Side.RIGHT);
-    r.setAttribute('line-num', 'FILE');
-
-    const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r]);
-  });
-
-  suite('syntax layer with syntax_highlighting on', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('rendering normal-sized diff does not disable syntax', () => {
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      assert.isTrue(element.syntaxLayer.enabled);
-    });
-
-    test('rendering large diff disables syntax', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('starts syntax layer processing on render event', async () => {
-      sinon.stub(element.syntaxLayer, 'process')
-          .returns(Promise.resolve());
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      element.dispatchEvent(
-          new CustomEvent('render', {bubbles: true, composed: true}));
-      assert.isTrue(element.syntaxLayer.process.called);
-    });
-  });
-
-  suite('syntax layer with syntax_highlighting off', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = prefs;
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('syntax layer should be disabled', () => {
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('still disabled for large diff', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-  });
-
-  suite('coverage layer', () => {
-    let notifyStub;
-    let coverageProviderStub;
-    const exampleRanges = [
-      {
-        type: CoverageType.COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: CoverageType.NOT_COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-    ];
-
-    setup(async () => {
-      notifyStub = sinon.stub();
-      coverageProviderStub = sinon.stub().returns(
-          Promise.resolve(exampleRanges));
-
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
-          Promise.resolve([{
-            notify: notifyStub,
-            getCoverageProvider() {
-              return coverageProviderStub;
-            },
-          }]));
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
-      await flush();
-    });
-
-    test('getCoverageAnnotationApis should be called', async () => {
-      await element.reload();
-      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
-    });
-
-    test('coverageRangeChanged should be called', async () => {
-      await element.reload();
-      assert.equal(notifyStub.callCount, 2);
-      assert.isTrue(notifyStub.calledWithExactly(
-          'some/path', 1, 2, Side.RIGHT));
-      assert.isTrue(notifyStub.calledWithExactly(
-          'some/path', 3, 4, Side.RIGHT));
-    });
-
-    test('provider is called with appropriate params', async () => {
-      element.patchRange.basePatchNum = 1;
-      element.patchRange.patchNum = 3;
-
-      await element.reload();
-      assert.isTrue(coverageProviderStub.calledWithExactly(
-          123, 'some/path', 1, 3, element.change));
-    });
-
-    test('provider is called with appropriate params - special patchset values',
-        async () => {
-          element.patchRange.basePatchNum = 'PARENT';
-          element.patchRange.patchNum = 'invalid';
-
-          await element.reload();
-          assert.isTrue(coverageProviderStub.calledWithExactly(
-              123, 'some/path', undefined, undefined, element.change));
-        });
-  });
-
-  suite('trailing newlines', () => {
-    setup(() => {
-    });
-
-    suite('_lastChunkForSide', () => {
-      test('deltas', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar'], b: ['baz']},
-          {ab: ['foo', 'bar', 'baz']},
-          {b: ['foo']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
-
-        diff.content.push({a: ['foo'], b: ['bar']});
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-      });
-
-      test('addition with a undefined', () => {
-        const diff = {content: [
-          {b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('addition with a empty', () => {
-        const diff = {content: [
-          {a: [], b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('deletion with b undefined', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz']},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('deletion with b empty', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz'], b: []},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('empty', () => {
-        const diff = {content: []};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-    });
-
-    suite('_hasTrailingNewlines', () => {
-      test('shared no trailing', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({ab: ['foo', 'bar']});
-        assert.isFalse(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('delta trailing in right', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({a: ['foo', 'bar'], b: ['baz', '']});
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('addition', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (leftSide) { return null; }
-          return {b: ['foo', '']};
-        });
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isNull(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('deletion', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (!leftSide) { return null; }
-          return {a: ['foo']};
-        });
-        assert.isNull(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..446d569
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -0,0 +1,1725 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-host';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  Side,
+} from '../../../constants/constants';
+import {
+  createBlame,
+  createChange,
+  createComment,
+  createCommentThread,
+  createDiff,
+  createPatchRange,
+  createRunResult,
+} from '../../../test/test-data-generators';
+import {
+  addListenerForTest,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  BlameInfo,
+  CommentRange,
+  CommitId,
+  EditPatchSetNum,
+  ImageInfo,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchSetNum,
+  RepoName,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CoverageType} from '../../../types/types';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffHost, LineInfo} from './gr-diff-host';
+import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
+import {ErrorCallback} from '../../../api/rest';
+import {SinonStub} from 'sinon';
+import {RunResult} from '../../../models/checks/checks-model';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotationActionsInterface} from '../../shared/gr-js-api-interface/gr-annotation-actions-js-api';
+
+const basicFixture = fixtureFromElement('gr-diff-host');
+
+suite('gr-diff-host tests', () => {
+  let element: GrDiffHost;
+  let loggedIn: boolean;
+
+  setup(async () => {
+    loggedIn = false;
+    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
+    element = basicFixture.instantiate();
+    element.changeNum = 123 as NumericChangeId;
+    element.path = 'some/path';
+    await flush();
+  });
+
+  suite('plugin layers', () => {
+    let getDiffLayersStub: sinon.SinonStub;
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
+    setup(() => {
+      element = basicFixture.instantiate();
+      getDiffLayersStub = sinon
+        .stub(element.jsAPI, 'getDiffLayers')
+        .returns(pluginLayers);
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'some/path';
+    });
+    test('plugin layers requested', async () => {
+      element.change = createChange();
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      await element.reload();
+      assert(getDiffLayersStub.called);
+    });
+  });
+
+  suite('render reporting', () => {
+    test('ends total and syntax timer after syntax layer', async () => {
+      const displayedStub = stubReporting('diffViewContentDisplayed');
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = createDefaultDiffPrefs();
+      element.reload(true);
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      notifySyntaxProcessed();
+      await waitUntil(() => timeEndStub.callCount === 4);
+      const calls = timeEndStub.getCalls();
+      assert.equal(calls.length, 4);
+      assert.equal(calls[0].args[0], 'Diff Load Render');
+      assert.equal(calls[1].args[0], 'Diff Content Render');
+      assert.equal(calls[2].args[0], 'Diff Syntax Render');
+      assert.equal(calls[3].args[0], 'Diff Total Render');
+      assert.isTrue(displayedStub.called);
+    });
+
+    test('completes reload promise after syntax layer processing', async () => {
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      let reloadComplete = false;
+      element.prefs = createDefaultDiffPrefs();
+      element.reload().then(() => {
+        reloadComplete = true;
+      });
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      await waitUntil(() => reloadComplete);
+      assert.isTrue(reloadComplete);
+    });
+  });
+
+  test('reload() cancels before network resolves', () => {
+    const cancelStub = sinon.stub(element.$.diff, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
+    element.patchRange = createPatchRange();
+    element.change = createChange();
+
+    // Needs to be set to something first for it to cancel.
+    element.diff = createDiff();
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  test('reload() loads files weblinks', async () => {
+    element.change = createChange();
+    const weblinksStub = sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .returns({name: 'stubb', url: '#s'});
+    stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+    element.projectName = 'test-project' as RepoName;
+    element.path = 'test-path';
+    element.commitRange = {
+      baseCommit: 'test-base' as CommitId,
+      commit: 'test-commit' as CommitId,
+    };
+    element.patchRange = createPatchRange();
+
+    await element.reload();
+
+    assert.equal(weblinksStub.callCount, 3);
+    assert.deepEqual(weblinksStub.firstCall.args[0], {
+      commit: 'test-base' as CommitId,
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project' as RepoName,
+      type: GerritNav.WeblinkType.EDIT,
+    });
+    assert.deepEqual(element.editWeblinks, [
+      {
+        name: 'stubb',
+        url: '#s',
+      },
+    ]);
+    assert.deepEqual(weblinksStub.secondCall.args[0], {
+      commit: 'test-base' as CommitId,
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project' as RepoName,
+      type: GerritNav.WeblinkType.FILE,
+    });
+    assert.deepEqual(weblinksStub.thirdCall.args[0], {
+      commit: 'test-commit' as CommitId,
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project' as RepoName,
+      type: GerritNav.WeblinkType.FILE,
+    });
+    assert.deepEqual(element.filesWeblinks, {
+      meta_a: [{name: 'stubb', url: '#s'}],
+      meta_b: [{name: 'stubb', url: '#s'}],
+    });
+  });
+
+  test('prefetch getDiff', async () => {
+    const diffRestApiStub = stubRestApi('getDiff').returns(
+      Promise.resolve(createDiff())
+    );
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    element.prefetchDiff();
+    await element._getDiff();
+    assert.isTrue(diffRestApiStub.calledOnce);
+  });
+
+  test('_getDiff handles undefined diff responses', async () => {
+    stubRestApi('getDiff').returns(Promise.resolve(undefined));
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    await element._getDiff();
+  });
+
+  test('reload resolves on error', () => {
+    const onErrStub = sinon.stub(element, '_handleGetDiffError');
+    const error = new Response(null, {status: 500});
+    stubRestApi('getDiff').callsFake(
+      (
+        _1: NumericChangeId,
+        _2: PatchSetNum,
+        _3: PatchSetNum,
+        _4: string,
+        _5?: IgnoreWhitespaceType,
+        onErr?: ErrorCallback
+      ) => {
+        if (onErr) onErr(error);
+        return Promise.resolve(undefined);
+      }
+    );
+    element.patchRange = createPatchRange();
+    return element.reload().then(() => {
+      assert.isTrue(onErrStub.calledOnce);
+    });
+  });
+
+  suite('_handleGetDiffError', () => {
+    let serverErrorStub: sinon.SinonStub;
+    let pageErrorStub: sinon.SinonStub;
+
+    setup(() => {
+      serverErrorStub = sinon.stub();
+      addListenerForTest(document, 'server-error', serverErrorStub);
+      pageErrorStub = sinon.stub();
+      addListenerForTest(document, 'page-error', pageErrorStub);
+    });
+
+    test('page error on HTTP-409', () => {
+      element._handleGetDiffError({status: 409} as Response);
+      assert.isTrue(serverErrorStub.calledOnce);
+      assert.isFalse(pageErrorStub.called);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('server error on non-HTTP-409', () => {
+      element._handleGetDiffError({
+        status: 500,
+        text: () => Promise.resolve(''),
+      } as Response);
+      assert.isFalse(serverErrorStub.called);
+      assert.isTrue(pageErrorStub.calledOnce);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('error message if showLoadFailure', () => {
+      element.showLoadFailure = true;
+      element._handleGetDiffError({
+        status: 500,
+        statusText: 'Failure!',
+      } as Response);
+      assert.isFalse(serverErrorStub.called);
+      assert.isFalse(pageErrorStub.called);
+      assert.equal(
+        element._errorMessage,
+        'Encountered error when loading the diff: 500 Failure!'
+      );
+    });
+  });
+
+  suite('image diffs', () => {
+    let mockFile1: ImageInfo;
+    let mockFile2: ImageInfo;
+    setup(() => {
+      mockFile1 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+        type: 'image/bmp',
+      };
+      mockFile2 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+        type: 'image/bmp',
+      };
+
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+    });
+
+    test('renders image diffs with same file name', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const diffTable = element.$.diff.$.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, 'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(leftLabelName);
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(rightLabelName);
+    });
+
+    test('renders image diffs with a different file name', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot2.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot2.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot2.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const diffTable = element.$.diff.$.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, 'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(rightLabelName);
+      assert.isOk(leftLabelName);
+      assert.equal(leftLabelName?.textContent, mockDiff.meta_a?.name);
+      assert.equal(rightLabelName?.textContent, mockDiff.meta_b?.name);
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+    });
+
+    test('renders added image', async () => {
+      const mockDiff: DiffInfo = {
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'ADDED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 0000000..f9c2f2c 100644',
+          '--- /dev/null',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: null,
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot2.jpg',
+          },
+        })
+      );
+
+      const promise = mockPromise();
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+          element.$.diff.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+
+        const diffTable = element.$.diff.$.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
+        promise.resolve();
+      });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await promise;
+    });
+
+    test('renders removed image', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      const promise = mockPromise();
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+          element.$.diff.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+
+        const diffTable = element.$.diff.$.diffTable;
+
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
+        promise.resolve();
+      });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await promise;
+    });
+
+    test('does not render disallowed image type', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {
+          name: 'carrot.jpg',
+          content_type: 'image/jpeg-evil',
+          lines: 560,
+        },
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      mockFile1.type = 'image/jpeg-evil';
+
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      const promise = mockPromise();
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+          element.$.diff.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+        const diffTable = element.$.diff.$.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+        promise.resolve();
+      });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await promise;
+    });
+  });
+
+  test('cannot create comments when not logged in', () => {
+    element.patchRange = createPatchRange();
+    const showAuthRequireSpy = sinon.spy();
+    element.addEventListener('show-auth-required', showAuthRequireSpy);
+
+    element.dispatchEvent(
+      new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 3,
+          side: Side.LEFT,
+          path: '/p',
+        },
+      })
+    );
+
+    const threads = queryAll(element.$.diff, 'gr-comment-thread');
+    assert.equal(threads.length, 0);
+    assert.isTrue(showAuthRequireSpy.called);
+  });
+
+  test('delegates cancel()', () => {
+    const stub = sinon.stub(element.$.diff, 'cancel');
+    element.patchRange = createPatchRange();
+    element.cancel();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    const stub = sinon
+      .stub(element.$.diff, 'getCursorStops')
+      .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    const stub = sinon
+      .stub(element.$.diff, 'isRangeSelected')
+      .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'some/path';
+      await flush();
+    });
+
+    test('clearBlame', () => {
+      element._blame = [];
+      const setBlameSpy = sinon.spy(element.$.diff.diffBuilder, 'setBlame');
+      element.clearBlame();
+      assert.isNull(element._blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.equal(element.isBlameLoaded, false);
+    });
+
+    test('loadBlame', () => {
+      const mockBlame: BlameInfo[] = [createBlame()];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      const getBlameStub = stubRestApi('getBlame').returns(
+        Promise.resolve(mockBlame)
+      );
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      return element.loadBlame().then(() => {
+        assert.isTrue(
+          getBlameStub.calledWithExactly(
+            changeNum,
+            1 as RevisionPatchSetNum,
+            'foo/bar.baz',
+            true
+          )
+        );
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element._blame, mockBlame);
+        assert.equal(element.isBlameLoaded, true);
+      });
+    });
+
+    test('loadBlame empty', () => {
+      const mockBlame: BlameInfo[] = [];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      return element
+        .loadBlame()
+        .then(() => {
+          assert.isTrue(false, 'Promise should not resolve');
+        })
+        .catch(() => {
+          assert.isTrue(showAlertStub.calledOnce);
+          assert.isNull(element._blame);
+          assert.equal(element.isBlameLoaded, false);
+        });
+    });
+  });
+
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('gr-comment-thread');
+    threadEl.className = 'comment-thread';
+    element.$.diff.appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
+
+  test('delegates clearDiffContent()', () => {
+    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
+    element.toggleAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in noAutoRender', () => {
+    const value = true;
+    element.noAutoRender = value;
+    assert.equal(element.$.diff.noAutoRender, value);
+  });
+
+  test('passes in path', () => {
+    const value = 'some/file/path';
+    element.path = value;
+    assert.equal(element.$.diff.path, value);
+  });
+
+  test('passes in prefs', () => {
+    const value = createDefaultDiffPrefs();
+    element.prefs = value;
+    assert.equal(element.$.diff.prefs, value);
+  });
+
+  test('passes in displayLine', () => {
+    const value = true;
+    element.displayLine = value;
+    assert.equal(element.$.diff.displayLine, value);
+  });
+
+  test('passes in hidden', () => {
+    const value = true;
+    element.hidden = value;
+    assert.equal(element.$.diff.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', () => {
+    const value = true;
+    element.lineWrapping = value;
+    assert.equal(element.$.diff.lineWrapping, value);
+  });
+
+  test('passes in viewMode', () => {
+    const value = DiffViewMode.SIDE_BY_SIDE;
+    element.viewMode = value;
+    assert.equal(element.$.diff.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', () => {
+    const value = {lineNum: 123, side: Side.LEFT};
+    element.lineOfInterest = value;
+    assert.equal(element.$.diff.lineOfInterest, value);
+  });
+
+  suite('_reportDiff', () => {
+    let reportStub: SinonStub;
+
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'file.txt';
+      element.patchRange = createPatchRange(1, 2);
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      await flush();
+    });
+
+    test('undefined', () => {
+      element._reportDiff(undefined);
+      assert.isFalse(reportStub.called);
+    });
+
+    test('diff w/ no delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{ab: ['foo', 'bar']}, {ab: ['baz', 'foo']}],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 50,
+        })
+      );
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+            due_to_rebase: true,
+          },
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 100,
+        })
+      );
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange = createPatchRange();
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+          },
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  suite('createCheckEl method', () => {
+    test('start_line:12', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {start_line: 12} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-12');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '12');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 without char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {path: 'a', range: {start_line: 13, end_line: 14} as CommentRange},
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 with char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {
+            path: 'a',
+            range: {
+              start_line: 13,
+              end_line: 14,
+              start_character: 5,
+              end_character: 7,
+            },
+          },
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(
+        el.getAttribute('range'),
+        '{"start_line":13,' +
+          '"end_line":14,' +
+          '"start_character":5,' +
+          '"end_character":7}'
+      );
+      assert.equal(el.result, result);
+    });
+
+    test('empty range', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-FILE');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), 'FILE');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+  });
+
+  suite('create-comment', () => {
+    setup(async () => {
+      loggedIn = true;
+      element.connectedCallback();
+      await flush();
+    });
+
+    test('creates comments if they do not exist yet', async () => {
+      element.patchRange = createPatchRange();
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 3,
+            side: Side.LEFT,
+            path: '/p',
+          },
+        })
+      );
+
+      let threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread?.range, undefined);
+      assert.equal(threads[0].thread?.patchNum, 1 as PatchSetNum);
+
+      // Try to fetch a thread with a different range.
+      const range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
+      };
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 1,
+            side: Side.LEFT,
+            path: '/p',
+            range,
+          },
+        })
+      );
+
+      threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread?.range, range);
+      assert.equal(threads[1].thread?.patchNum, 1 as PatchSetNum);
+    });
+
+    test('should not be on parent if on the right', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+          },
+        })
+      );
+      await flush();
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+    });
+
+    test('should be on parent if right and base is PARENT', () => {
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should be on parent if right and base negative', () => {
+      element.patchRange = createPatchRange(-2, 3);
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should not be on parent otherwise', () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test(
+      'thread should use old file path if first created ' +
+        'on patch set (left) before renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await flush();
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        const threads =
+          element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.basePath);
+      }
+    );
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (right) after renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await flush();
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.RIGHT,
+              path: '/p',
+            },
+          })
+        );
+
+        const threads =
+          element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('multiple threads created on the same range', async () => {
+      element.patchRange = createPatchRange(2, 3);
+
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      await flush();
+
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3 as PatchSetNum,
+      };
+      const thread = createCommentThread([comment]);
+      element.threads = [thread];
+
+      let threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      element.threads = [...element.threads, thread];
+
+      threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      // Threads have same rootId so element is reused
+      assert.equal(threads.length, 1);
+
+      const newThread = {...thread};
+      newThread.rootId = 'differentRootId' as UrlEncodedCommentId;
+      element.threads = [...element.threads, newThread];
+      threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      // New thread has a different rootId
+      assert.equal(threads.length, 2);
+    });
+
+    test('unsaved thread changes to draft', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      element.threads = [];
+      await flush();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+            path: element.path,
+            lineNum: 13,
+          },
+        })
+      );
+      await flush();
+      assert.equal(element.getThreadEls().length, 1);
+      const threadEl = element.getThreadEls()[0];
+      assert.equal(threadEl.thread?.line, 13);
+      assert.isDefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 0);
+
+      const draftThread = createCommentThread([
+        {
+          path: element.path,
+          patch_set: 3 as PatchSetNum,
+          line: 13,
+          __draft: true,
+        },
+      ]);
+      element.threads = [draftThread];
+      await flush();
+
+      // We expect that no additional thread element was created.
+      assert.equal(element.getThreadEls().length, 1);
+      // In fact the thread element must still be the same.
+      assert.equal(element.getThreadEls()[0], threadEl);
+      // But it must have been updated from unsaved to draft:
+      assert.isUndefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 1);
+    });
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (left) but is base',
+      async () => {
+        element.patchRange = createPatchRange();
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await flush();
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        const threads =
+          element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('cannot create thread on an edit', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.RIGHT;
+      element.patchRange = {
+        basePatchNum: 3 as BasePatchSetNum,
+        patchNum: EditPatchSetNum,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+
+    test('cannot create thread on an edit base', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: EditPatchSetNum,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      const threads =
+        element.$.diff.querySelectorAll<GrCommentThread>('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+  });
+
+  test('_filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+    const threads: GrCommentThread[] = [];
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threads, line, Side.LEFT),
+      []
+    );
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threads, line, Side.RIGHT),
+      []
+    );
+  });
+
+  test('_filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('gr-comment-thread');
+    l3.setAttribute('line-num', '3');
+    l3.setAttribute('diff-side', Side.LEFT);
+
+    const l5 = document.createElement('gr-comment-thread');
+    l5.setAttribute('line-num', '5');
+    l5.setAttribute('diff-side', Side.LEFT);
+
+    const r3 = document.createElement('gr-comment-thread');
+    r3.setAttribute('line-num', '3');
+    r3.setAttribute('diff-side', Side.RIGHT);
+
+    const r5 = document.createElement('gr-comment-thread');
+    r5.setAttribute('line-num', '5');
+    r5.setAttribute('diff-side', Side.RIGHT);
+
+    const threadEls: GrCommentThread[] = [l3, l5, r3, r5];
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l3]
+    );
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r5]
+    );
+  });
+
+  test('_filterThreadElsForLocation for file comments', () => {
+    const line: LineInfo = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('gr-comment-thread');
+    l.setAttribute('diff-side', Side.LEFT);
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('gr-comment-thread');
+    r.setAttribute('diff-side', Side.RIGHT);
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls: GrCommentThread[] = [l, r];
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l]
+    );
+    assert.deepEqual(
+      element._filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r]
+    );
+  });
+
+  suite('syntax layer with syntax_highlighting on', () => {
+    setup(() => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      await element.reload();
+      assertIsDefined(element.$.diff.layers);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+    });
+
+    test('rendering normal-sized diff does not disable syntax', () => {
+      element.diff = createDiff();
+      assert.isTrue(element.syntaxLayer.enabled);
+    });
+
+    test('rendering large diff disables syntax', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        ...createDiff(),
+        content: [
+          {
+            a: [new Array(501).join('*')],
+          },
+        ],
+      };
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('starts syntax layer processing on render event', async () => {
+      const stub = sinon
+        .stub(element.syntaxLayer, 'process')
+        .returns(Promise.resolve());
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      await element.reload();
+      element.dispatchEvent(
+        new CustomEvent('render', {bubbles: true, composed: true})
+      );
+      assert.isTrue(stub.called);
+    });
+  });
+
+  suite('syntax layer with syntax_highlighting off', () => {
+    setup(() => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: false,
+      };
+      element.diff = createDiff();
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      stubRestApi('getDiff').returns(Promise.resolve(createDiff()));
+      await element.reload();
+      assertIsDefined(element.$.diff.layers);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', () => {
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        ...createDiff(),
+        content: [
+          {
+            a: [new Array(501).join('*')],
+          },
+        ],
+      };
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+  });
+
+  suite('coverage layer', () => {
+    let notifyStub: SinonStub;
+    let coverageProviderStub: SinonStub;
+    let getCoverageAnnotationApisStub: SinonStub;
+    const exampleRanges = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+    ];
+
+    setup(async () => {
+      notifyStub = sinon.stub();
+      coverageProviderStub = sinon
+        .stub()
+        .returns(Promise.resolve(exampleRanges));
+
+      element = basicFixture.instantiate();
+      getCoverageAnnotationApisStub = sinon
+        .stub(element.jsAPI, 'getCoverageAnnotationApis')
+        .returns(
+          Promise.resolve([
+            {
+              notify: notifyStub,
+              getCoverageProvider() {
+                return coverageProviderStub;
+              },
+            } as unknown as GrAnnotationActionsInterface,
+          ])
+        );
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        ...createDiff(),
+        content: [{a: ['foo']}],
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
+      await flush();
+    });
+
+    test('getCoverageAnnotationApis should be called', async () => {
+      await element.reload();
+      assert.isTrue(getCoverageAnnotationApisStub.calledOnce);
+    });
+
+    test('coverageRangeChanged should be called', async () => {
+      await element.reload();
+      assert.equal(notifyStub.callCount, 2);
+      assert.isTrue(
+        notifyStub.calledWithExactly('some/path', 1, 2, Side.RIGHT)
+      );
+      assert.isTrue(
+        notifyStub.calledWithExactly('some/path', 3, 4, Side.RIGHT)
+      );
+    });
+
+    test('provider is called with appropriate params', async () => {
+      element.patchRange = createPatchRange(1, 3);
+
+      await element.reload();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          1,
+          3,
+          element.change
+        )
+      );
+    });
+
+    test('provider is called with appropriate params - special patchset values', async () => {
+      element.patchRange = createPatchRange();
+
+      await element.reload();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          undefined,
+          1,
+          element.change
+        )
+      );
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {});
+
+    suite('_lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ],
+        };
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: [], b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz']}],
+        };
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz'], b: []}],
+        };
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff: DiffInfo = {...createDiff(), content: []};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('_hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sinon.stub(element, '_lastChunkForSide').returns({ab: ['foo', 'bar']});
+        assert.isFalse(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sinon
+          .stub(element, '_lastChunkForSide')
+          .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, '_lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (leftSide) {
+              return null;
+            }
+            return {b: ['foo', '']};
+          });
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isNull(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, '_lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (!leftSide) {
+              return null;
+            }
+            return {a: ['foo']};
+          });
+        assert.isNull(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index dc501c8..2e48771 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -20,7 +20,6 @@
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
-import '../../../test/mocks/comment-api';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {stubRestApi} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/lit/incremental-repeat.ts b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
new file mode 100644
index 0000000..cf14cc1
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
@@ -0,0 +1,109 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {directive, AsyncDirective} from 'lit/async-directive.js';
+import {DirectiveParameters, ChildPart} from 'lit/directive.js';
+import {
+  insertPart,
+  setChildPartValue,
+  removePart,
+} from 'lit/directive-helpers.js';
+
+interface RepeatOptions<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  initialCount: number;
+  targetFrameRate?: number;
+  startAt?: number;
+  // TODO: targetFramerate
+}
+
+interface RepeatState<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  startAt: number;
+  incrementAmount: number;
+  lastRenderedAt: number;
+  targetFrameRate: number;
+}
+
+class IncrementalRepeat<T> extends AsyncDirective {
+  private parts: ChildPart[] = [];
+
+  private part!: ChildPart;
+
+  private state!: RepeatState<T>;
+
+  render(options: RepeatOptions<T>) {
+    const values = options.values.slice(
+      options.startAt ?? 0,
+      (options.startAt ?? 0) + options.initialCount
+    );
+    if (options.mapFn) {
+      return values.map(options.mapFn);
+    }
+    return values;
+  }
+
+  override update(part: ChildPart, [options]: DirectiveParameters<this>) {
+    if (
+      options.values !== this.state?.values ||
+      options.mapFn !== this.state?.mapFn
+    ) {
+      if (this.nextScheduledFrameWork !== undefined)
+        cancelAnimationFrame(this.nextScheduledFrameWork);
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+      this.part = part;
+      for (let i = 0; i < this.parts.length; i++) {
+        removePart(this.parts[i]);
+      }
+      this.parts = [];
+      this.state = {
+        values: options.values,
+        mapFn: options.mapFn,
+        startAt: options.initialCount,
+        incrementAmount: options.initialCount,
+        lastRenderedAt: performance.now(),
+        targetFrameRate: options.targetFrameRate ?? 30,
+      };
+    }
+    return this.render(options);
+  }
+
+  private nextScheduledFrameWork: number | undefined;
+
+  private animationFrameHandler = () => {
+    const now = performance.now();
+    const frameRate = 1000 / (now - this.state.lastRenderedAt);
+    if (frameRate < this.state.targetFrameRate) {
+      // https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
+      this.state.incrementAmount = Math.max(1, this.state.incrementAmount / 2);
+    } else {
+      this.state.incrementAmount++;
+    }
+    this.state.lastRenderedAt = now;
+    const part = insertPart(this.part);
+    this.parts.push(part);
+    setChildPartValue(
+      part,
+      this.render({
+        mapFn: this.state.mapFn,
+        values: this.state.values,
+        initialCount: this.state.incrementAmount,
+        startAt: this.state.startAt,
+      })
+    );
+    this.state.startAt += this.state.incrementAmount;
+    if (this.state.startAt < this.state.values.length) {
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+    }
+  };
+}
+
+export const incrementalRepeat = directive(IncrementalRepeat);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 73be9f3..a09cdbc 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -17,7 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -136,14 +135,7 @@
     if (!this.serverConfig?.change) return true;
     if (column === ColumnNames.COMMENTS)
       return this.flagsService.isEnabled('comments-column');
-    if (column === ColumnNames.STATUS)
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    if (column === ColumnNames.STATUS2)
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === ColumnNames.STATUS) return false;
     return true;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index fdea387..42ef8f4 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -71,12 +71,6 @@
             </td>
           </tr>
           <tr>
-            <td><label for="Status"> Status </label></td>
-            <td class="checkboxContainer">
-              <input checked="" id="Status" name="Status" type="checkbox" />
-            </td>
-          </tr>
-          <tr>
             <td><label for="Owner"> Owner </label></td>
             <td class="checkboxContainer">
               <input checked="" id="Owner" name="Owner" type="checkbox" />
@@ -117,6 +111,12 @@
               <input id="Size" name="Size" type="checkbox" />
             </td>
           </tr>
+          <tr>
+            <td><label for=" Status "> Status </label></td>
+            <td class="checkboxContainer">
+              <input id=" Status " name=" Status " type="checkbox" />
+            </td>
+          </tr>
         </tbody>
       </table>
     </div>`);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 81661e2..c4bafac 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -376,9 +376,7 @@
                 @change=${this.handleToggleDark}
                 @click=${this.onTapDarkToggle}
               ></paper-toggle-button>
-              <div id="darkThemeToggleLabel">
-                Dark theme (the toggle reloads the page)
-              </div>
+              <div id="darkThemeToggleLabel">Dark theme</div>
             </div>
           </section>
           <h2
@@ -451,8 +449,8 @@
               >Save changes</gr-button
             >
           </fieldset>
-          <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
-          <gr-menu-editor></gr-menu-editor>
+          <gr-edit-preferences id="EditPreferences"></gr-edit-preferences>
+          <gr-menu-editor id="Menu"></gr-menu-editor>
           <h2
             id="ChangeTableColumns"
             class=${this.computeHeaderClass(this.changeTableChanged)}
@@ -1161,6 +1159,7 @@
 
   // private but used in test
   reloadPage() {
+    fireAlert(this, 'Reloading...');
     windowLocationReload();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index a514f00..6a8f575 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -177,7 +177,7 @@
               >
               </paper-toggle-button>
               <div id="darkThemeToggleLabel">
-                Dark theme (the toggle reloads the page)
+                Dark theme
               </div>
             </div>
           </section>
@@ -369,8 +369,8 @@
               Save changes
             </gr-button>
           </fieldset>
-          <gr-edit-preferences id="editPrefs"> </gr-edit-preferences>
-          <gr-menu-editor> </gr-menu-editor>
+          <gr-edit-preferences id="EditPreferences"> </gr-edit-preferences>
+          <gr-menu-editor id="Menu"> </gr-menu-editor>
           <h2 id="ChangeTableColumns">Change Table Columns</h2>
           <fieldset id="changeTableColumns">
             <gr-change-table-editor> </gr-change-table-editor>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 689a9fb..7f20a1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -27,7 +27,6 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {ClassInfo, classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
@@ -94,8 +93,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -252,12 +249,7 @@
   }
 
   private computeVoteClasses(): ClassInfo {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
-      !this.label ||
-      !this.account ||
-      !hasVoted(this.label, this.account)
-    ) {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
       return {};
     }
     const status = getLabelStatus(this.label, this.vote?.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 48d6998..3fecd63 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -167,9 +167,6 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
@@ -203,7 +200,7 @@
       .group {
         --account-label-suffix: ' (group)';
       }
-      .pending-add {
+      .pendingAdd {
         font-style: italic;
       }
       .list {
@@ -234,8 +231,7 @@
       </div>
       <gr-account-entry
         borderless=""
-        ?hidden=${(this.maxCount && this.maxCount <= this.accounts.length) ||
-        this.readonly}
+        ?hidden=${this.readonly}
         id="entry"
         .placeholder=${this.placeholder}
         @add=${this.handleAdd}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 7b3a93d..26566a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -400,16 +400,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', async () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct, count: 1});
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
-    );
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 48f5d09..3181445 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -22,7 +22,6 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -206,12 +205,6 @@
     ];
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   override render() {
     return html`<paper-button
       ?raised=${!this.link && !this.flatten}
@@ -220,8 +213,7 @@
       tabindex="-1"
       part="paper-button"
       class=${classMap({
-        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
-        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
+        newVoteChip: this.voteChip,
       })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index cb51337..7d52a3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -305,6 +305,8 @@
     // We have to wrap boolean values in Boolean() to ensure undefined values
     // use false rather than undefined.
     return (
+      Boolean(this.originalDiffPrefs?.syntax_highlighting) !==
+        Boolean(this.diffPrefs?.syntax_highlighting) ||
       this.originalDiffPrefs?.context !== this.diffPrefs?.context ||
       Boolean(this.originalDiffPrefs?.line_wrapping) !==
         Boolean(this.diffPrefs?.line_wrapping) ||
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 79c73f1..82528cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -133,19 +133,16 @@
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionOverflow');
-    // TODO(TS): remove return, unclear why it was written
     this.ensureEl().setActionOverflow(type, key, overflow);
   }
 
   setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionPriority');
-    // TODO(TS): remove return, unclear why it was written
     this.ensureEl().setActionPriority(type, key, priority);
   }
 
   setActionHidden(type: ActionType, key: string, hidden: boolean) {
     this.reporting.trackApi(this.plugin, 'actions', 'setActionHidden');
-    // TODO(TS): remove return, unclear why it was written
     this.ensureEl().setActionHidden(type, key, hidden);
   }
 
@@ -156,7 +153,6 @@
 
   remove(key: string) {
     this.reporting.trackApi(this.plugin, 'actions', 'remove');
-    // TODO(TS): remove return, unclear why it was written
     this.ensureEl().removeActionButton(key);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b1d3914..ee3bec7 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -18,11 +18,9 @@
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {
@@ -30,9 +28,7 @@
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
@@ -40,10 +36,8 @@
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
-  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -61,19 +55,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -107,8 +88,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -118,9 +97,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -132,33 +108,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -173,17 +122,10 @@
         gr-button[disabled] iron-icon {
           color: var(--border-color);
         }
-        gr-account-label {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
         iron-icon {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
         }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
-        }
         .reviewer-row {
           padding-top: var(--spacing-s);
         }
@@ -208,14 +150,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
@@ -238,23 +172,6 @@
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
@@ -285,30 +202,6 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-label
-          clickable
-          .account=${mappedLabel.account}
-          .change=${change}
-        ></gr-account-label>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
@@ -341,83 +234,6 @@
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -488,39 +304,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 0ac49a7..f1336b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -20,20 +20,18 @@
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
-import {GrAccountLabel} from '../gr-account-label/gr-account-label';
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -41,12 +39,51 @@
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+      <div class="reviewer-row">
+        <gr-account-chip>
+          <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+        </gr-account-chip>
+        <gr-tooltip-content has-tooltip="" title="Remove vote">
+          <gr-button
+            aria-disabled="false"
+            aria-label="Remove vote"
+            class="deleteBtn hidden"
+            data-account-id="5"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <iron-icon icon="gr-icons:delete"> </iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    </div>`);
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +99,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -108,101 +149,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLabel>(element, 'gr-account-label');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +164,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index 146a01e..5859731 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -23,8 +23,6 @@
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -61,8 +59,6 @@
   @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
   tooltipWithWhoVoted = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -131,9 +127,6 @@
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a5effaf..47f5f81 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -462,7 +462,7 @@
     numLines: number,
     referenceLine: number
   ) {
-    assertIsDefined(this.diff, 'diff');
+    if (!this.diff?.meta_b) return;
     const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 269b56d..25d3398 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -1,24 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
@@ -26,7 +13,6 @@
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 import {CancelablePromise, makeCancelable} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -40,11 +26,7 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {
-  DiffViewMode,
-  RenderPreferences,
-  RenderProgressEventDetail,
-} from '../../../api/diff';
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {
@@ -53,18 +35,26 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent, fire} from '../../../utils/event-util';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
 declare global {
   interface HTMLElementEventMap {
-    'render-progress': CustomEvent<RenderProgressEventDetail>;
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
   }
 }
 
@@ -97,112 +87,59 @@
   }
 }
 
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement
-  extends PolymerElement
-  implements GroupConsumer
-{
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering - both for full renders and for
-   * partial rerenders.
-   *
-   * @event render-start
-   */
-
-  /**
-   * Fired whenever a new chunk of lines has been rendered synchronously - this
-   * only happens for full renders.
-   *
-   * @event render-progress
-   */
-
-  /**
-   * Fired when the diff finishes rendering text content - both for full
-   * renders and for partial rerenders.
-   *
-   * @event render-content
-   */
-
-  @property({type: Object})
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
   diff?: DiffInfo;
 
-  @property({type: String})
+  diffElement?: HTMLTableElement;
+
   viewMode?: string;
 
-  @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Object})
   baseImage: ImageInfo | null = null;
 
-  @property({type: Object})
   revisionImage: ImageInfo | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
   path?: string;
 
-  @property({type: Object})
   prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
 
-  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
-  _builder?: DiffBuilder;
-
-  /**
-   * The gr-diff-processor adds (and only adds!) to this array. It does so by
-   * using `this.push()` and Polymer's two-way data binding.
-   * Below (@observe('_groups.splices')) we are observing the groups that the
-   * processor adds, and pass them on to the builder for rendering. Henceforth
-   * the builder groups are the source of truth, because when
-   * expanding/collapsing groups only the builder is updated. This field and the
-   * corresponsing one in the processor are not updated.
-   */
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
+  useNewImageDiffUi = false;
 
   /**
    * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
    */
-  @property({type: Array})
   layers: DiffLayer[] = [];
 
+  // visible for testing
+  builder?: DiffBuilder;
+
   /**
-   * All layers, both from the outside and the default ones.
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
    */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
 
-  @property({type: Boolean})
-  _showTabs?: boolean;
+  // visible for testing
+  showTabs?: boolean;
 
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array, observer: 'coverageObserver'})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
+  // visible for testing
+  showTrailingWhitespace?: boolean;
 
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
    * method that rejects it with `{isCancelled: true}`.
    */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
@@ -210,51 +147,30 @@
 
   private rangeLayer = new GrRangedCommentLayer();
 
-  private processor = new GrDiffProcessor();
+  // visible for testing
+  processor = new GrDiffProcessor();
+
+  /**
+   * 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.
+   *
+   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+   * separation of responsibilities.
+   */
+  private groups: GrDiffGroup[] = [];
 
   constructor() {
-    super();
-    afterNextRender(this, () => {
-      this.addEventListener(
-        'diff-context-expanded',
-        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
-          // Don't stop propagation. The host may listen for reporting or
-          // resizing.
-          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-        }
-      );
-    });
     this.processor.consumer = this;
   }
 
-  override disconnectedCallback() {
-    this.processor.cancel();
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer.updateRanges(ranges);
   }
 
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
-  }
-
-  @observe('commentRanges.*')
-  rangeObserver() {
-    this.rangeLayer.updateRanges(this.commentRanges);
-  }
-
-  coverageObserver(coverageRanges: CoverageRange[]) {
-    const leftRanges = coverageRanges.filter(
-      range => range && range.side === Side.LEFT
-    );
-    this.coverageLayerLeft.setRanges(leftRanges);
-
-    const rightRanges = coverageRanges.filter(
-      range => range && range.side === Side.RIGHT
-    );
-    this.coverageLayerRight.setRanges(rightRanges);
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
   render(keyLocations: KeyLocations): void {
@@ -262,62 +178,94 @@
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
-    this._setupAnnotationLayers();
+    this.setupAnnotationLayers();
 
-    this._showTabs = this.prefs.show_tabs;
-    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
 
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder();
+    this.builder?.clear();
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    this.builder = this.getDiffBuilder();
 
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
 
-    this._clearDiffContent();
-    this._builder.addColumns(
+    this.diffElement.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+
+    this.clearDiffContent();
+    this.builder.addColumns(
       this.diffElement,
       getLineNumberCellWidth(this.prefs)
     );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = makeCancelable(
-      this.processor
-        .process(this.diff.content, isBinary)
-        .then(() => {
-          if (this.isImageDiff) {
-            (this._builder as GrDiffBuilderImage).renderDiff();
-          }
-          afterNextRender(this, () => fireEvent(this, '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;
-        })
-        .finally(() => {
-          this._cancelableRenderPromise = null;
-        })
+    this.fireDiffEvent('render-start');
+    // TODO: processor.process() returns a cancelable promise already.
+    // Why wrap another one around it?
+    this.cancelableRenderPromise = makeCancelable(
+      this.processor.process(this.diff.content, isBinary)
     );
+    // All then/catch/finally clauses must be outside of makeCancelable().
+    this.cancelableRenderPromise
+      .then(async () => {
+        if (this.isImageDiff) {
+          (this.builder as GrDiffBuilderImage).renderDiff();
+        }
+        await this.untilGroupsRendered();
+        this.fireDiffEvent('render-content');
+      })
+      // Mocha testing does not like uncaught rejections, so we catch
+      // the cancels which are expected and should not throw errors in
+      // tests.
+      .catch(e => {
+        if (!e.isCanceled) return Promise.reject(e);
+        return;
+      })
+      .finally(() => {
+        this.cancelableRenderPromise = null;
+      });
   }
 
-  _setupAnnotationLayers() {
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    for (const g of groups) {
+      // The LOST or FILE lines may be hidden and thus never resolve an
+      // untilRendered() promise.
+      const lineNumber = g.lines?.[0]?.beforeNumber;
+      if (g.skip || lineNumber === 'LOST' || lineNumber === 'FILE') continue;
+      assertIsDefined(g.element);
+      await untilRendered(g.element);
+    }
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fireEvent(this.diffElement, type);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
     const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
       this.rangeLayer,
       this.coverageLayerLeft,
       this.coverageLayerRight,
@@ -326,15 +274,15 @@
     if (this.layers) {
       layers.push(...this.layers);
     }
-    this._layers = layers;
+    this.layersInternal = layers;
   }
 
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
   }
 
-  _getDiffRowByChild(child: Element) {
+  private getDiffRowByChild(child: Element) {
     while (!child.classList.contains('diff-row') && child.parentElement) {
       child = child.parentElement;
     }
@@ -348,23 +296,23 @@
     const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
-    const row = this._getDiffRowByChild(lineEl);
+    const row = this.getDiffRowByChild(lineEl);
     return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this._builder) return null;
-    return this._builder.getLineElByNumber(lineNumber, side);
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
   }
 
   getLineNumberRows() {
-    if (!this._builder) return [];
-    return this._builder.getLineNumberRows();
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
   }
 
   getLineNumEls(side: Side) {
-    if (!this._builder) return [];
-    return this._builder.getLineNumEls(side);
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
   }
 
   /**
@@ -376,8 +324,8 @@
    * @param side The side the line number refer to.
    */
   unhideLine(lineNum: number, side: Side) {
-    if (!this._builder) return;
-    const group = this._builder.findGroup(side, lineNum);
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -420,45 +368,47 @@
     contextGroup: GrDiffGroup,
     newGroups: readonly GrDiffGroup[]
   ) {
-    if (!this._builder) return;
-    fireEvent(this, 'render-start');
-    const linesRendered = newGroups.reduce(
-      (sum, group) => sum + group.lines.length,
-      0
-    );
-    this._builder.replaceGroup(contextGroup, newGroups);
-    afterNextRender(this, () => {
-      fire(this, 'render-progress', {linesRendered});
-      fireEvent(this, 'render-content');
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start');
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      this.fireDiffEvent('render-content');
     });
   }
 
   cancel() {
     this.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
+    this.builder?.clear();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
   }
 
-  _handlePreferenceError(pref: string): never {
+  // visible for testing
+  handlePreferenceError(pref: string): never {
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    fireAlert(this, message);
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(): DiffBuilder {
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
+      this.handlePreferenceError('tab size');
     }
 
     if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
+      this.handlePreferenceError('diff width');
     }
 
     const localPrefs = {...this.prefs};
@@ -487,7 +437,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
@@ -495,7 +445,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     }
@@ -505,7 +455,8 @@
     return builder;
   }
 
-  _clearDiffContent() {
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
     this.diffElement.innerHTML = '';
   }
 
@@ -514,22 +465,22 @@
    * server into chunks.
    */
   clearGroups() {
-    if (!this._builder) return;
-    this._builder.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]);
-    afterNextRender(this, () =>
-      fire(this, 'render-progress', {linesRendered: group.lines.length})
-    );
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
   }
 
-  _createIntralineLayer(): DiffLayer {
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
       // differences to highlight and apply them to the element as
@@ -561,8 +512,9 @@
     };
   }
 
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // If visible tabs are disabled, do nothing.
@@ -576,7 +528,7 @@
     };
   }
 
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // Find and annotate the locations of soft hyphen (\u00AD)
@@ -592,8 +544,9 @@
     };
   }
 
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
@@ -621,18 +574,12 @@
   }
 
   setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame ?? []);
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
+    this.builder?.updateRenderPrefs(renderPrefs);
     this.processor.updateRenderPrefs(renderPrefs);
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
deleted file mode 100644
index 8c15ddd..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1084 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode, Side} from '../../../api/diff.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
-import {waitForEventOnce} from '../../../utils/event-util.js';
-
-const basicFixture = fixtureFromTemplate(html`
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-// GrDiffBuilderElement forces these prefs to be set - tests that do not care
-// about these values can just set these defaults.
-const DEFAULT_PREFS = {
-  line_length: 10,
-  show_tabs: true,
-  tab_size: 4,
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilderLegacy({content: []}, prefs);
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder.createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', async () => {
-      element.diff = {content};
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isFalse(processStub.lastCall.args[1]);
-    });
-
-    test('image', async () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
-    });
-
-    test('binary', async () => {
-      element.diff = {content, binary: true};
-      element.render(keyLocations);
-      await waitForEventOnce(element, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.prefs = prefs;
-      await element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(3)'),
-        outputEl.querySelector('stub:nth-of-type(4)'),
-      ];
-      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      await new Promise(resolve => afterNextRender(element, resolve));
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      const afterNextRenderPromise = new Promise((resolve, reject) => {
-        afterNextRender(element, resolve);
-      });
-      element.diff = {
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      await element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await afterNextRenderPromise;
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton = contextControls[0].shadowRoot
-          .querySelectorAll('.showContext')[0];
-      assert.include(topExpandCommonButton.textContent, '+9 common lines');
-      topExpandCommonButton.click();
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      element.dispatchEvent.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      await new Promise(resolve => afterNextRender(element, resolve));
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = createDiff();
-      element.diff = diff;
-
-      keyLocations = {left: {}, right: {}};
-
-      element.prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      await element.render(keyLocations);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('renderContentByRange', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder.renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('renderContentByRange non-existent elements', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-
-      sinon.stub(builder, 'getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder.renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
-          .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      expect(result).dom.to.equal(/* HTML */`
-        <span class="gr-diff style-scope">
-          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
-            12/12/2019
-          </a>
-          <span class="blameAuthor gr-diff style-scope">Clark</span>
-          <gr-hovercard class="gr-diff style-scope">
-            <span class="blameHoverCard gr-diff style-scope">
-              Commit 1234567890<br>
-              Author: Clark Kent<br>
-              Date: 12/12/2019<br>
-              <br>
-              Testing Commit
-            </span>
-          </gr-hovercard>
-        </span>
-      `);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..d37afc0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1127 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="style-scope gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.style.display = 'block';
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await waitUntil(() => dispatchStub.callCount >= 1);
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+
+      await waitUntil(() => dispatchStub.callCount >= 2);
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  suite('mock-diff', () => {
+    let builder: GrDiffBuilderSideBySide;
+    let diff: DiffInfo;
+    let keyLocations: KeyLocations;
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      diff = createDiff();
+      element.diff = diff;
+
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.right'
+      )[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.left'
+      )[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+        deltaLineNumberButton.getAttribute('aria-label'),
+        '10 removed'
+      );
+    });
+
+    test('getContentByLine', () => {
+      let actual: HTMLElement | null;
+
+      actual = builder.getContentByLine(2, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(2, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(5, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+      actual = builder.getContentByLine(5, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+    });
+
+    test('getContentTdByLineEl works both with button and td', () => {
+      const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+      const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+      const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonLeft),
+        contentTdLeft
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumTdRight),
+        contentTdRight
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonRight),
+        contentTdRight
+      );
+    });
+
+    test('findLinesByRange', () => {
+      const lines: GrDiffLine[] = [];
+      const elems: HTMLElement[] = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder.renderContentByRange(start, end, Side.LEFT);
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i: number) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('renderContentByRange non-existent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+
+      sinon
+        .stub(builder, 'getLineNumberEl')
+        .returns(document.createElement('div'));
+      sinon
+        .stub(builder, 'findLinesByRange')
+        .callsFake((_1, _2, _3, lines, elements) => {
+          // Add a line and a corresponding element.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          const tr = document.createElement('tr');
+          const td = document.createElement('td');
+          const el = document.createElement('div');
+          tr.appendChild(td);
+          td.appendChild(el);
+          elements?.push(el);
+
+          // Add 2 lines without corresponding elements.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+        });
+
+      builder.renderContentByRange(1, 10, Side.LEFT);
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getLineNumberEl unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(startElem);
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      expect(result).dom.to.equal(/* HTML */ `
+        <span class="gr-diff style-scope">
+          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
+            12/12/2019
+          </a>
+          <span class="blameAuthor gr-diff style-scope">Clark</span>
+          <gr-hovercard class="gr-diff style-scope">
+            <span class="blameHoverCard gr-diff style-scope">
+              Commit 1234567890<br />
+              Author: Clark Kent<br />
+              Date: 12/12/2019<br />
+              <br />
+              Testing Commit
+            </span>
+          </gr-hovercard>
+        </span>
+      `);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index ceadc94..c04d156 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -162,10 +162,8 @@
    *
    * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
    */
-  private getLineNumberEl(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
     let row: HTMLElement | null = content;
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
@@ -349,7 +347,8 @@
     });
   }
 
-  protected createTextEl(
+  // visible for testing
+  createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
     side?: Side
@@ -491,7 +490,8 @@
    * Create a blame cell for the given base line. Blame information will be
    * included in the cell if available.
    */
-  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
     const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
     blameTd.setAttribute('data-line-number', lineNumber.toString());
     if (!lineNumber) return blameTd;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index a711215..f2690bc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -44,7 +44,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup) {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -147,7 +148,8 @@
     return td;
   }
 
-  protected override getNextContentOnSide(
+  // visible for testing
+  override getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4145485..0c9d1d9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -43,7 +43,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 5f3fb72..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..7a9d06d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,282 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index add7ffa..4b664e2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -89,7 +89,8 @@
 
   protected readonly numLinesLeft: number;
 
-  protected readonly _prefs: DiffPreferencesInfo;
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
 
   protected readonly renderPrefs?: RenderPreferences;
 
@@ -194,7 +195,8 @@
     group.element = element;
   }
 
-  private getGroupsByLineRange(
+  // visible for testing
+  getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
     side: Side
@@ -257,7 +259,8 @@
    *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
    *        really need to support null/undefined? Also change to camelCase.
    */
-  protected findLinesByRange(
+  // visible for testing
+  findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
@@ -352,9 +355,8 @@
    *
    * @return The commit information.
    */
-  protected getBlameCommitForBaseLine(
-    lineNum: LineNumber
-  ): BlameInfo | undefined {
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index dfe8a15..e00353a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -109,7 +109,8 @@
    */
   initialLineNumber: number | null = null;
 
-  private cursorManager = new GrCursorManager();
+  // visible for testing
+  cursorManager = new GrCursorManager();
 
   private targetSubscription?: Subscription;
 
@@ -335,10 +336,6 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderProgress = () => {
-    this._updateStops();
-  };
-
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -550,10 +547,6 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
-    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -569,10 +562,6 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index f48d673..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,681 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = await fixture(html`<gr-diff></gr-diff>`);
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = createDiff();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  test('moveToFirstChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {b: ['new line 1']},
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    // The file comment button, if present, is a cursor stop. Ensure
-    // moveToFirstChunk() works correctly even if the button is not shown.
-    diffElement.prefs.show_file_comment_button = false;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-        {b: ['new line 3']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      // We must allow the diff to re-render after setting the viewMode.
-      await new Promise(resolve => afterNextRender(diffElement, resolve));
-      cursor.reInitCursor();
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
-    });
-  });
-
-  suite('moved chunks (moveDetails)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 4, end: 6}},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 2, end: 4}},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    diffElement._diffChanged(createDiff());
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor.reInitCursor();
-    assert.isFalse(moveToNumStub.called);
-    assert.isTrue(moveToChunkStub.called);
-    assert.equal(scrollBehaviorDuringMove, 'never');
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(createDiff());
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    cursor.reInitCursor();
-    assert.isFalse(moveToChunkStub.called);
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], 10);
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await new Promise(resolve => afterNextRender(diffElement, resolve));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    let diffElements;
-
-    setup(async () => {
-      diffElements = [
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-      ];
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(diffElements);
-
-      for (const el of diffElements) {
-        el.prefs = createDefaultDiffPrefs();
-      }
-    });
-
-    function getTargetDiffIndex() {
-      // Mocha has a bug where when `assert.equals` fails, it will try to
-      // JSON.stringify the operands, which fails when they are cyclic structures
-      // like GrDiffElement. The failure is difficult to attribute to a specific
-      // assertion because of the async nature assertion errors are handled and
-      // can cause the test simply timing out, causing a lot of debugging headache.
-      // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursor.getTargetDiffElement());
-    }
-
-    test('do not skip loading diffs', async () => {
-      const diffRenderedPromises =
-          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
-
-      diffElements[0].diff = createDiff();
-      diffElements[2].diff = createDiff();
-      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
-      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
-
-      const lastLine = diffElements[0].diff.meta_b.lines;
-
-      // Goto second last line of the first diff
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = createDiff();
-      await diffRenderedPromises[1];
-      await new Promise(resolve => afterNextRender(diffElements[0], resolve));
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..ac9b407
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,693 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursor.diffRow);
+
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta .diff-row'
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+
+    cursor.moveDown();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+
+    cursor.moveUp();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursor.diffRow);
+
+      const firstDeltaRow = queryAndAssert<HTMLElement>(
+        diffElement,
+        '.section.delta .diff-row'
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+
+      cursor.moveUp();
+
+      assert.notEqual(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta'
+    );
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      firstDeltaSection,
+      '.diff-row'
+    );
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(
+      cursor.diffRow!.parentElement,
+      firstDeltaSection.previousSibling
+    );
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const indexOfChunk = function (chunk: HTMLElement) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 6f71c65..cc44f50 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
@@ -138,6 +138,12 @@
       return Promise.resolve();
     }
 
+    // TODO: Canceling this promise does not help much. `nextStep` will continue
+    // to be scheduled anyway. So either just remove the cancelable promise, so
+    // future programmers are not fooled about this promise can do. Or fix the
+    // scheduling of `nextStep` such that cancellation is taken into account.
+    // The easiest approach is likely to just not re-use the processor for
+    // multiple processing passes. There is no benefit from doing so.
     this.processPromise = makeCancelable(
       new Promise(resolve => {
         const state = {
@@ -614,6 +620,7 @@
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
       result.push({
         lines: lines.slice(lastChunkEndOffset, offset),
         keyLocation,
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 b8262e0..e015d49 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
@@ -17,6 +17,7 @@
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
 import {LineNumber} from './gr-diff-line';
+import {assertIsDefined, check} from '../../../utils/common-util';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -275,7 +276,9 @@
             },
           };
         } else {
-          for (const line of options.lines ?? []) {
+          assertIsDefined(options.lines);
+          check(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
             this.addLine(line);
           }
         }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
similarity index 60%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 321086c..bbf03ae 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -1,32 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+import '../../../test/common-test-setup-karma';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    let group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-      l1, l2, l3,
-    ]});
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
@@ -53,13 +46,25 @@
     ]);
   });
 
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
   test('group/header line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
     const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
     const group = new GrDiffGroup({
-      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -83,34 +88,44 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    assert.throws(() =>
-      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
   });
 
   suite('hideInContextControl', () => {
-    let groups;
+    let groups: GrDiffGroup[];
     setup(() => {
       groups = [
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
-          new GrDiffLine(GrDiffLineType.REMOVE, 10),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
-          new GrDiffLine(GrDiffLineType.REMOVE, 11),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]}),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
       ];
     });
 
@@ -140,21 +155,25 @@
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
 
       assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
 
       assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
@@ -166,19 +185,26 @@
           type: GrDiffGroupType.BOTH,
           skip: 60,
           offsetLeft: 8,
-          offsetRight: 10});
+          offsetRight: 10,
+        });
         groups = [
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
           skipGroup,
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
         ];
       });
 
@@ -189,13 +215,11 @@
     });
 
     test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
     });
 
     test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
     });
   });
 
@@ -206,7 +230,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('is total for remove', () => {
@@ -215,12 +239,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('not total for non-delta', () => {
@@ -229,8 +248,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
   });
 });
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index e5f9de0..27952d3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -43,11 +43,7 @@
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
@@ -81,6 +77,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {deepEqual} from '../../../utils/deep-util';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -100,7 +97,6 @@
 
 export interface GrDiff {
   $: {
-    diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
 }
@@ -179,7 +175,7 @@
   @property({type: Object})
   highlightRange?: CommentRange;
 
-  @property({type: Array})
+  @property({type: Array, observer: '_coverageRangesObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean, observer: '_lineWrappingObserver'})
@@ -248,9 +244,6 @@
   @property({type: Object, observer: '_blameChanged'})
   blame: BlameInfo[] | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
@@ -292,11 +285,16 @@
   @property({type: Boolean})
   isAttached = false;
 
-  private renderDiffTableTask?: DelayedTask;
+  // visible for testing
+  renderDiffTableTask?: DelayedTask;
 
   private diffSelection = new GrDiffSelection();
 
-  private highlights = new GrDiffHighlight();
+  // visible for testing
+  highlights = new GrDiffHighlight();
+
+  // visible for testing
+  diffBuilder = new GrDiffBuilderElement();
 
   constructor() {
     super();
@@ -321,11 +319,12 @@
     this._unobserveNodes();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
+    this.diffBuilder.cancel();
     super.disconnectedCallback();
   }
 
   getLineNumEls(side: Side): HTMLElement[] {
-    return this.$.diffBuilder.getLineNumEls(side);
+    return this.diffBuilder.getLineNumEls(side);
   }
 
   showNoChangeMessage(
@@ -426,19 +425,25 @@
           cr.side === removedCommentRange.side &&
           rangesEqual(cr.range, removedCommentRange.range)
       );
-      this.splice('_commentRanges', i, 1);
+      this._commentRanges.splice(i, 1);
     }
 
-    if (addedCommentRanges && addedCommentRanges.length) {
-      this.push('_commentRanges', ...addedCommentRanges);
+    if (addedCommentRanges?.length) {
+      this._commentRanges.push(...addedCommentRanges);
     }
     if (this.highlightRange) {
-      this.push('_commentRanges', {
+      this._commentRanges.push({
         side: Side.RIGHT,
         range: this.highlightRange,
         rootId: '',
       });
     }
+
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+  }
+
+  _coverageRangesObserver() {
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
   }
 
   /**
@@ -483,7 +488,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diffBuilder.cancel();
+    this.diffBuilder.cancel();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -492,7 +497,7 @@
 
     // Get rendered stops.
     const stops: Array<HTMLElement | AbortStop> =
-      this.$.diffBuilder.getLineNumberRows();
+      this.diffBuilder.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -512,7 +517,7 @@
 
   _blameChanged(newValue?: BlameInfo[] | null) {
     if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
+    this.diffBuilder.setBlame(newValue);
     if (newValue) {
       this.classList.add('showBlame');
     } else {
@@ -534,7 +539,7 @@
     return classes.join(' ');
   }
 
-  _handleTap(e: CustomEvent) {
+  _handleTap(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
     if (
@@ -603,7 +608,7 @@
 
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
@@ -621,7 +626,7 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
@@ -663,28 +668,11 @@
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+    if (!deepEqual(newPrefs, oldPrefs)) {
       this._prefsChanged(newPrefs);
     }
   }
 
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
   _pathObserver() {
     // Call _prefsChanged(), because line-limit style value depends on path.
     this._prefsChanged(this.prefs);
@@ -699,7 +687,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.$.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   _cleanup() {
@@ -808,7 +796,7 @@
     if (this.prefs) {
       this._updatePreferenceStyles(this.prefs, renderPrefs);
     }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
+    this.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
@@ -820,7 +808,7 @@
     }
     if (this.diff) {
       this.diffSelection.init(this.diff, this.$.diffTable);
-      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+      this.highlights.init(this.$.diffTable, this.diffBuilder);
     }
   }
 
@@ -866,9 +854,24 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.renderPrefs = this.renderPrefs;
-    this.$.diffBuilder.render(keyLocations);
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.$.diffTable;
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    this.diffBuilder.render(keyLocations);
   }
 
   _handleRenderContent() {
@@ -895,10 +898,7 @@
         const commentSide = getSide(threadEl);
         const range = getRange(threadEl);
         if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
+        const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
         // When the line the comment refers to does not exist, log an error
         // but don't crash. This can happen e.g. if the API does not fully
         // validate e.g. (robot) comments
@@ -911,7 +911,7 @@
           );
           continue;
         }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+        const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
         if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
           contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index 6d36b89..a1082f9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -349,7 +349,7 @@
       background-color: var(--dark-rebased-remove-highlight-color);
     }
     .dueToRebase .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
+      background-color: var(--light-rebased-remove-highlight-color);
     }
 
     /* dueToMove */
@@ -698,36 +698,22 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-builder
-      id="diffBuilder"
-      comment-ranges="[[_commentRanges]]"
-      coverage-ranges="[[coverageRanges]]"
-      diff="[[diff]]"
-      path="[[path]]"
-      view-mode="[[viewMode]]"
-      is-image-diff="[[isImageDiff]]"
-      base-image="[[baseImage]]"
-      layers="[[layers]]"
-      revision-image="[[revisionImage]]"
-      use-new-image-diff-ui="[[useNewImageDiffUi]]"
-    >
-      <table
-        id="diffTable"
-        class$="[[_diffTableClass]]"
-        role="presentation"
-        contenteditable$="[[isContentEditable]]"
-      ></table>
+    <table
+      id="diffTable"
+      class$="[[_diffTableClass]]"
+      role="presentation"
+      contenteditable$="[[isContentEditable]]"
+    ></table>
 
-      <template
-        is="dom-if"
-        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-      >
-        <div class="whitespace-change-only-message">
-          This file only contains whitespace changes. Modify the whitespace
-          setting to see the changes.
-        </div>
-      </template>
-    </gr-diff-builder>
+    <template
+      is="dom-if"
+      if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+    >
+      <div class="whitespace-change-only-message">
+        This file only contains whitespace changes. Modify the whitespace
+        setting to see the changes.
+      </div>
+    </template>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
similarity index 62%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index c8b643d..1422223 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -1,32 +1,37 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {runA11yAudit} from '../../../test/a11y-test-utils.js';
-import '@polymer/paper-button/paper-button.js';
-import {Side} from '../../../api/diff.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-import {AbortStop} from '../../../api/core.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {waitForEventOnce} from '../../../utils/event-util.js';
+import '../../../test/common-test-setup-karma';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -37,42 +42,51 @@
 });
 
 suite('gr-diff tests', () => {
-  let element;
+  let element: GrDiff;
 
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
 
-  setup(() => {
-
-  });
+  setup(() => {});
 
   suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
       document.dispatchEvent(new CustomEvent('selectionchange'));
     };
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.highlights, 'handleSelectionChange');
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.highlights.handleSelectionChange.called);
+      assert.isTrue(handleSelectionChangeStub.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.highlights.handleSelectionChange.called);
+      assert.isFalse(handleSelectionChangeStub.called);
     });
   });
 
   test('cancel', () => {
     element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
+    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
     element.cancel();
     assert.isTrue(cancelStub.calledOnce);
   });
@@ -98,10 +112,12 @@
     });
 
     test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
+      element.prefs = {...element.prefs!, line_length: 100};
       flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
     });
 
     test('content-width should not be defined', () => {
@@ -123,32 +139,40 @@
     });
 
     test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
     });
 
     test('max-width considers one content column in unified', () => {
-      element.viewMode = 'UNIFIED_DIFF';
+      element.viewMode = DiffViewMode.UNIFIED;
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
     });
 
     test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
+      element.prefs = {...element.prefs!, font_size: 13};
       flush();
       // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
     });
 
     test('sign cols are considered if show_sign_col is true', () => {
       element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
       flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)');
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
     });
   });
 
@@ -168,39 +192,31 @@
     });
 
     test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
     });
 
     test('displayLine class added called when displayLine is true', () => {
       const spy = sinon.spy(element, '_computeContainerClass');
       element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
       assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
+      assert.isTrue(container.classList.contains('displayLine'));
     });
 
     test('thread groups', () => {
       const contentEl = document.createElement('div');
 
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
       element.path = 'file.txt';
-      element.$.diffBuilder.diff = createDiff();
-      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
 
       // No thread groups.
       assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
 
       // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+      const threadGroupEl = element._getOrCreateThreadGroup(
+        contentEl,
+        Side.LEFT
+      );
       assert.isOk(threadGroupEl);
 
       // The new thread group can be fetched.
@@ -208,17 +224,19 @@
     });
 
     suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
       setup(() => {
         mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
           type: 'image/bmp',
         };
         mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
           type: 'image/bmp',
         };
 
@@ -235,7 +253,6 @@
           show_whitespace_errors: true,
           syntax_highlighting: true,
           tab_size: 8,
-          theme: 'DEFAULT',
         };
       });
 
@@ -244,8 +261,7 @@
         element.revisionImage = mockFile2;
         element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
@@ -262,42 +278,40 @@
 
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
         // Left image rendered with the parent commit's version of the file.
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = query(leftLabel, '.name');
 
-        const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = query(rightLabel, '.name');
 
         assert.isNotOk(rightLabelName);
         assert.isNotOk(leftLabelName);
 
-        assert.isOk(leftImage);
-        assert.equal(leftImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile1.body);
-        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
 
-        assert.isOk(rightImage);
-        assert.equal(rightImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile2.body);
-        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
       });
 
       test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
+        const mockDiff: DiffInfo = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
@@ -312,51 +326,51 @@
         };
 
         element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
+        element.baseImage._name = mockDiff.meta_a!.name;
         element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
+        element.revisionImage._name = mockDiff.meta_b!.name;
         element.diff = mockDiff;
         await waitForEventOnce(element, 'render');
 
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
         // Left image rendered with the parent commit's version of the file.
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = queryAndAssert(leftLabel, '.name');
 
-        const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = queryAndAssert(rightLabel, '.name');
 
         assert.isOk(rightLabelName);
         assert.isOk(leftLabelName);
-        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
 
         assert.isOk(leftImage);
-        assert.equal(leftImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile1.body);
-        assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
 
         assert.isOk(rightImage);
-        assert.equal(rightImage.getAttribute('src'),
-            'data:image/bmp;base64,' + mockFile2.body);
-        assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
       });
 
       test('renders added image', async () => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'ADDED',
           diff_header: [
@@ -371,7 +385,9 @@
         };
 
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.revisionImage = mockFile2;
@@ -380,20 +396,17 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
         assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
+        queryAndAssert(diffTable, 'td.right img');
       });
 
       test('renders removed image', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           intraline_status: 'OK',
           change_type: 'DELETED',
           diff_header: [
@@ -407,7 +420,9 @@
           binary: true,
         };
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
@@ -416,20 +431,21 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
 
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
+        const diffTable = element.$.diffTable;
+        queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
         assert.isNotOk(rightImage);
       });
 
       test('does not render disallowed image type', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
           intraline_status: 'OK',
           change_type: 'DELETED',
           diff_header: [
@@ -445,7 +461,9 @@
         mockFile1.type = 'image/jpeg-evil';
 
         const promise = mockPromise();
-        function rendered() { promise.resolve(); }
+        function rendered() {
+          promise.resolve();
+        }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
@@ -454,9 +472,9 @@
         element.removeEventListener('render', rendered);
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
         assert.isNotOk(leftImage);
       });
     });
@@ -513,7 +531,6 @@
           show_tabs: true,
           show_whitespace_errors: true,
           syntax_highlighting: true,
-          theme: 'DEFAULT',
           ignore_whitespace: 'IGNORE_NONE',
         };
 
@@ -548,20 +565,20 @@
         const FILE_ROW = 1;
         const actual = element.getCursorStops();
         assert.equal(actual.length, ROWS + FILE_ROW + 1);
-        assert.isTrue(actual[actual.length -1] instanceof AbortStop);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
       });
     });
 
     test('adds .hiddenscroll', () => {
       _setHiddenScroll(true);
       element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.include(container.className, 'hiddenscroll');
     });
   });
 
   suite('logged in', () => {
-    let fakeLineEl;
+    let fakeLineEl: HTMLElement;
     setup(() => {
       element = basicFixture.instantiate();
       element.loggedIn = true;
@@ -571,15 +588,14 @@
         classList: {
           contains: sinon.stub().returns(true),
         },
-      };
+      } as unknown as HTMLElement;
     });
 
     test('addDraftAtLine', () => {
       sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
+      const createCommentStub = sinon.stub(element, '_createComment');
       element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
     });
 
     test('adds long range comment hint', async () => {
@@ -592,23 +608,28 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('line-num', '1');
       threadEl.setAttribute('range', JSON.stringify(range));
       threadEl.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
       setupSampleDiff({content});
-      await new Promise(resolve => afterNextRender(element, resolve));
+      await waitForEventOnce(element, 'render');
 
       element.appendChild(threadEl);
-      await flush();
+      await waitForEventOnce(element, 'render');
 
-      assert.deepEqual(
-          element.querySelector('gr-ranged-comment-hint').range, range);
+      const hint = queryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
     });
 
     test('no duplicate range hint for same thread', async () => {
@@ -621,109 +642,119 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('line-num', '1');
       threadEl.setAttribute('range', JSON.stringify(range));
       threadEl.setAttribute('slot', 'right-1');
       const firstHint = document.createElement('gr-ranged-comment-hint');
       firstHint.range = range;
-      firstHint.setAttribute('threadElRootId', threadEl.rootId);
       firstHint.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
       setupSampleDiff({content});
+      await waitForEventOnce(element, 'render');
 
       element.appendChild(firstHint);
-      await flush();
-      element._handleRenderContent();
-      await flush();
+      element.appendChild(threadEl);
+      await waitForEventOnce(element, 'render');
+
+      assert.equal(
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        1
+      );
+    });
+
+    test('removes long range comment hint when comment is discarded', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 7,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
       element.appendChild(threadEl);
       await flush();
 
-      assert.equal(
-          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
+      threadEl.remove();
+      await flush();
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
     });
 
-    test('removes long range comment hint when comment is discarded',
-        async () => {
-          const range = {
-            start_line: 1,
-            end_line: 7,
-            start_character: 0,
-            end_character: 0,
-          };
-          const threadEl = document.createElement('div');
-          threadEl.className = 'comment-thread';
-          threadEl.setAttribute('diff-side', 'right');
-          threadEl.setAttribute('line-num', 1);
-          threadEl.setAttribute('range', JSON.stringify(range));
-          threadEl.setAttribute('slot', 'right-1');
-          const content = [{
-            a: [],
-            b: [],
-          }, {
-            ab: Array(8).fill('text'),
-          }];
-          setupSampleDiff({content});
-          element.appendChild(threadEl);
-          await flush();
-
-          threadEl.remove();
-          await flush();
-
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-        });
-
     suite('change in preferences', () => {
       setup(() => {
         element.diff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
           diff_header: [],
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           content: [{skip: 66}],
         };
-        element.renderDiffTableTask.flush();
+        element.renderDiffTableTask?.flush();
       });
 
       test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
+        const stub = sinon.stub(element, '_renderDiffTable');
         element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+          ...MINIMAL_PREFS,
+        };
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
       });
 
       test('adding/removing property in preferences re-renders diff', () => {
         const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
         element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
         stub.reset();
 
         const newPrefs2 = {...newPrefs1};
         delete newPrefs2.line_wrapping;
         element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
       });
 
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        () => {
+          const stub = sinon.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
     });
   });
 
@@ -732,8 +763,7 @@
       element = basicFixture.instantiate();
       element.diff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
         diff_header: [],
         intraline_status: 'OK',
         change_type: 'MODIFIED',
@@ -755,11 +785,12 @@
       assert.equal(element._diffHeaderItems.length, 1);
       flush();
 
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
     });
 
     test('binary files', () => {
-      element.diff.binary = true;
+      element.diff!.binary = true;
       assert.equal(element._diffHeaderItems.length, 0);
       element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
       assert.equal(element._diffHeaderItems.length, 0);
@@ -771,16 +802,17 @@
   });
 
   suite('safety and bypass', () => {
-    let renderStub;
+    let renderStub: sinon.SinonStub;
 
     setup(() => {
       element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        const diffTable = element.$.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve({});
+      });
       sinon.stub(element, 'getDiffLength').returns(10000);
       element.diff = createDiff();
       element.noRenderOnPrefsChange = true;
@@ -838,7 +870,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element._safetyBypass, -1);
-      assert.equal(element.$.diffBuilder.prefs.context, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -851,7 +883,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element._safetyBypass);
-      assert.equal(element.$.diffBuilder.prefs.context, 3);
+      assert.equal(element.diffBuilder.prefs.context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -863,7 +895,7 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element._safetyBypass, 10);
-      assert.equal(element.$.diffBuilder.prefs.context, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
     });
   });
 
@@ -874,7 +906,7 @@
 
     test('unsetting', () => {
       element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
       element.classList.add('showBlame');
       element.blame = null;
       assert.isTrue(setBlameSpy.calledWithExactly(null));
@@ -882,7 +914,15 @@
     });
 
     test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
       assert.isTrue(element.classList.contains('showBlame'));
     });
   });
@@ -891,8 +931,10 @@
     const NO_NEWLINE_LEFT = 'No newline at end of left file.';
     const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
 
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
+    const getWarning = (element: GrDiff) => {
+      const warningElement = queryAndAssert(element, '.newlineWarning');
+      return warningElement.textContent;
+    };
 
     setup(() => {
       element = basicFixture.instantiate();
@@ -904,8 +946,9 @@
       element.showNewlineWarningLeft = true;
       element.showNewlineWarningRight = true;
       assert.include(
-          getWarning(element),
-          NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT);// \u2014 - '—'
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
     });
 
     suite('showNewlineWarningLeft', () => {
@@ -918,11 +961,6 @@
         element.showNewlineWarningLeft = false;
         assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
       });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
     });
 
     suite('showNewlineWarningRight', () => {
@@ -935,49 +973,25 @@
         element.showNewlineWarningRight = false;
         assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
       });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
     });
 
     test('_computeNewlineWarningClass', () => {
       const hidden = 'newlineWarning hidden';
       const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+      assert.equal(element._computeNewlineWarningClass(false, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(false, false), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, false), shown);
     });
   });
 
   suite('key locations', () => {
-    let renderStub;
+    let renderStub: sinon.SinonStub;
 
     setup(() => {
       element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
     });
 
     test('lineOfInterest is a key location', () => {
@@ -994,7 +1008,7 @@
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
       threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
+      threadEl.setAttribute('line-num', '3');
       element.appendChild(threadEl);
       flush();
 
@@ -1021,7 +1035,11 @@
       });
     });
   });
-  const setupSampleDiff = function(params) {
+  const setupSampleDiff = function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
     const {ignore_whitespace, content} = params;
     // binary can't be undefined, use false if not set
     const binary = params.binary || false;
@@ -1039,7 +1057,6 @@
       show_whitespace_errors: true,
       syntax_highlighting: true,
       tab_size: 8,
-      theme: 'DEFAULT',
     };
     element.diff = {
       intraline_status: 'OK',
@@ -1059,21 +1076,24 @@
   };
 
   test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
     function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
+      const diffTable = element.$.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
     }
     setupSampleDiff({content});
     assertDiffTableWithContent();
-    element.diff = {...element.diff};
+    element.diff = {...element.diff!};
     // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
+    const diffTable = element.$.diffTable;
+    assert.equal(diffTable.innerHTML, '');
     element._renderDiffTable();
     flush();
     // rendered again
@@ -1082,40 +1102,46 @@
 
   suite('selection test', () => {
     test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
       flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
+      mouseDown(diffLine);
       assert.equal(getComputedStyle(diffLine).userSelect, 'text');
     });
 
     test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
+      element.viewMode = DiffViewMode.UNIFIED;
       flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
+      mouseDown(diffLine);
       assert.equal(getComputedStyle(diffLine).userSelect, 'text');
     });
   });
@@ -1123,71 +1149,87 @@
   suite('whitespace changes only message', () => {
     test('show the message if ignore_whitespace is criteria matches', () => {
       setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
+      assert.isTrue(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message for binary files', () => {
       setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message if still loading', () => {
       setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ true,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({content});
       assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
 
     test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
       setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
+      assert.isFalse(
+        element.showNoChangeMessage(
           /* loading= */ false,
           element.prefs,
           element._diffLength,
           element.diff
-      ));
+        )
+      );
     });
   });
 
@@ -1195,21 +1237,4 @@
     const diff = createDiff();
     assert.equal(element.getDiffLength(diff), 52);
   });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
 });
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index bb46484..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {createDiffAppContext} from './gr-diff-app-context-init.js';
-
-suite('gr diff app context initializer tests', () => {
-  test('all services initialized and are singletons', () => {
-    const appContext = createDiffAppContext();
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..84fd859
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup-karma';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index c276f79..a34f880 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -9,14 +9,21 @@
   NumericChangeId,
   ChangeStatus,
   ReviewerState,
+  AccountId,
   AccountInfo,
+  GroupInfo,
 } from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
-import {ReviewInput, ReviewerInput} from '../../types/common';
+import {
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+} from '../../types/common';
+import {accountOrGroupKey} from '../../utils/account-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -154,14 +161,15 @@
   }
 
   addReviewers(
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
   ): Promise<Response>[] {
     const current = this.subject$.getValue();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
     );
     return changes.map(change => {
-      const reviewersNewToChange = [
+      const reviewersNewToChange: ReviewerInput[] = [
         ReviewerState.REVIEWER,
         ReviewerState.CC,
       ].flatMap(state =>
@@ -170,8 +178,20 @@
       if (reviewersNewToChange.length === 0) {
         return Promise.resolve(new Response());
       }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
       const reviewInput: ReviewInput = {
         reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
       };
       return this.restApiService.saveChangeReview(
         change._number,
@@ -242,14 +262,14 @@
   private getNewReviewersToChange(
     change: ChangeInfo,
     state: ReviewerState,
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
   ): ReviewerInput[] {
     return (
       changedReviewers
         .get(state)
         ?.filter(account => !change.reviewers[state]?.includes(account))
         .map(account => {
-          return {state, reviewer: account._account_id!};
+          return {state, reviewer: accountOrGroupKey(account)};
         }) ?? []
     );
   }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 5347b41..84d5c4e 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -7,6 +7,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
   createRevisions,
 } from '../../test/test-data-generators';
 import {
@@ -18,6 +19,7 @@
   AccountInfo,
   ReviewerState,
   AccountId,
+  GroupInfo,
 } from '../../api/rest-api';
 import {BulkActionsModel, LoadingState} from './bulk-actions-model';
 import {getAppContext} from '../../services/app-context';
@@ -200,6 +202,7 @@
       createAccountWithIdNameAndEmail(0),
       createAccountWithIdNameAndEmail(1),
     ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
     const changes: ChangeInfo[] = [
       {
         ...createChange(),
@@ -234,21 +237,49 @@
     test('adds reviewers/cc only to changes that need it', async () => {
       bulkActionsModel.addReviewers(
         new Map([
-          [ReviewerState.REVIEWER, [accounts[0]]],
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
           [ReviewerState.CC, [accounts[1]]],
-        ])
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
       );
 
-      // changes[0] is not updated since it already has the reviewer & CC
-      assert.isTrue(saveChangeReviewStub.calledOnce);
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
         changes[1]._number,
         'current',
         {
           reviewers: [
             {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index a1b732f..44d63d4b 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -28,7 +28,6 @@
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
-  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 5f77e8a..028b2af 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -62,7 +62,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -100,37 +100,37 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 2b4fc60..3193833 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -2702,20 +2702,22 @@
 
     return Promise.all([promiseA, promiseB]).then(results => {
       // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
       const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
       return imagesForDiff;
     });
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index e7de1ef..74b6997 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -73,7 +73,7 @@
             initializing = true;
             initialized.set(name, factory(context));
           } catch (e) {
-            console.error(`Failed to initialize ${name}`, e);
+            console.error(`Failed to initialize ${String(name)}`, e);
           } finally {
             initializing = false;
           }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index dc17ee6..97e0c01 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -372,9 +372,20 @@
 
     /* diff colors */
     --dark-add-highlight-color: #aaf2aa;
-    --dark-rebased-add-highlight-color: #d7d7f9;
-    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-add-highlight-color: #d8fed8;
     --dark-remove-highlight-color: #ffcdd2;
+    --light-remove-highlight-color: #ffebee;
+
+    --dark-rebased-add-highlight-color: #d7d7f9;
+    --light-rebased-add-highlight-color: #eef;
+    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-rebased-remove-highlight-color: #fff8dc;
+
+    --diff-moved-in-background: var(--cyan-50);
+    --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-out-background: var(--purple-50);
+    --diff-moved-out-label-color: var(--purple-900);
+
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
@@ -385,14 +396,6 @@
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --focused-line-outline-color: var(--blue-700);
-    --light-add-highlight-color: #d8fed8;
-    --light-rebased-add-highlight-color: #eef;
-    --diff-moved-in-background: var(--cyan-50);
-    --diff-moved-out-background: var(--purple-50);
-    --diff-moved-in-label-color: var(--cyan-900);
-    --diff-moved-out-label-color: var(--purple-900);
-    --light-remove-add-highlight-color: #fff8dc;
-    --light-remove-highlight-color: #ffebee;
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 6f19924..17c967d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -207,9 +207,20 @@
 
     /* diff colors */
     --dark-add-highlight-color: var(--green-tonal);
-    --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --light-add-highlight-color: #182b1f;
     --dark-remove-highlight-color: #62110f;
+    --light-remove-highlight-color: #320404;
+
+    --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
+    --light-rebased-add-highlight-color: #487165;
+    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --light-rebased-remove-highlight-color: #2f3f2f;
+
+    --diff-moved-in-background: #1d4042;
+    --diff-moved-in-label-color: var(--cyan-50);
+    --diff-moved-out-background: #230e34;
+    --diff-moved-out-label-color: var(--purple-50);
+
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #333311;
     --diff-context-control-border-color: var(--border-color);
@@ -220,14 +231,6 @@
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --focused-line-outline-color: var(--blue-200);
-    --light-add-highlight-color: #182b1f;
-    --light-rebased-add-highlight-color: #487165;
-    --diff-moved-in-background: #1d4042;
-    --diff-moved-out-background: #230e34;
-    --diff-moved-in-label-color: var(--cyan-50);
-    --diff-moved-out-label-color: var(--purple-50);
-    --light-remove-add-highlight-color: #2f3f2f;
-    --light-remove-highlight-color: #320404;
     --coverage-covered: #112826;
     --coverage-not-covered: #6b3600;
     --ranged-comment-hint-text-color: var(--blue-50);
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * This is an "abstract" class for tests. The descendant must define a template
- * for this element and a tagName - see createCommentApiMockWithTemplateElement below
- */
-class CommentApiMock extends LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 829a36b..e90ad2c 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -22,6 +22,7 @@
   ApprovalInfo,
   AuthInfo,
   BasePatchSetNum,
+  BlameInfo,
   BranchName,
   ChangeConfigInfo,
   ChangeId,
@@ -54,6 +55,7 @@
   MaxObjectSizeLimitInfo,
   MergeableInfo,
   NumericChangeId,
+  PatchRange,
   PatchSetNum,
   PluginConfigInfo,
   PreferencesInfo,
@@ -64,6 +66,7 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   RobotCommentInfo,
   RobotId,
   RobotRunId,
@@ -258,6 +261,16 @@
   };
 }
 
+export function createPatchRange(
+  basePatchNum?: number,
+  patchNum?: number
+): PatchRange {
+  return {
+    basePatchNum: (basePatchNum ?? 'PARENT') as BasePatchSetNum,
+    patchNum: (patchNum ?? 1) as RevisionPatchSetNum,
+  };
+}
+
 export function createRevision(
   patchSetNum = 1,
   description = ''
@@ -469,6 +482,24 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
 export function createDiff(): DiffInfo {
   return {
     meta_a: {
@@ -585,6 +616,16 @@
   };
 }
 
+export function createBlame(): BlameInfo {
+  return {
+    author: 'test-author',
+    id: 'test-id',
+    time: 123,
+    commit_msg: 'test-commit-message',
+    ranges: [],
+  };
+}
+
 export function createMergeable(): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
@@ -825,7 +866,9 @@
   };
 }
 
-export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
+export function createCommentThread(
+  comments: Array<Partial<CommentInfo | DraftInfo>>
+) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 562d47f..7ad656d 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -48,9 +48,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  meta_a?: DiffFileMetaInfo;
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index b7cc77b..b6018ba 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,7 +35,7 @@
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 6ccf770..95b753c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -159,7 +159,8 @@
 
 /**
  * Returns the elements that are present in every sub-array. If a compareBy
- * predicate is passed in, it will be used instead of strict equality.
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
  */
 export function intersection<T>(
   arrays: T[][],
@@ -171,6 +172,9 @@
   if (arrays.length === 0) {
     return [];
   }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
   return arrays.reduce((result, array) =>
     result.filter(t => array.find(u => compareBy(t, u)))
   );
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 8cc523a..0adfaa6 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -75,8 +75,11 @@
   });
 
   test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
     assert.sameDeepMembers(intersection([]), []);
-    assert.sameDeepMembers(intersection([[1, 2, 3]]), [1, 2, 3]);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
     assert.sameDeepMembers(
       intersection([
         [1, 2, 3],
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16e0586..be6aa06 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -491,8 +491,18 @@
   return false;
 }
 
+/** Returns a promise that waits for the element's height to become > 0. */
+export function untilRendered(el: HTMLElement) {
+  return new Promise(resolve => {
+    whenRendered(el, resolve);
+  });
+}
+
 /** Executes the given callback when the element's height is > 0. */
-export function whenRendered(el: HTMLElement, callback: () => void) {
+export function whenRendered(
+  el: HTMLElement,
+  callback: (value?: unknown) => void
+) {
   if (el.clientHeight > 0) {
     callback();
     return;
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 418adbd..e624cef 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -32,7 +32,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index f5703f4..2b6f700 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -21,7 +21,6 @@
   SubmitRequirementStatus,
   LabelNameToValuesMap,
 } from '../api/rest-api';
-import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -421,16 +420,3 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
-
-export function showNewSubmitRequirements(
-  flagsService: FlagsService,
-  change?: ParsedChangeInfo | ChangeInfo
-) {
-  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-  if (!isSubmitRequirementsUiEnabled) return false;
-  if ((getRequirements(change) ?? []).length === 0) return false;
-
-  return true;
-}
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
index da77234..6c645f4 100644
--- a/tools/js/template_checker.bzl
+++ b/tools/js/template_checker.bzl
@@ -123,9 +123,7 @@
     )
 
     # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir.
     pkg_tar(
         name = name + "_tar",
         srcs = generated_dev_files,
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index d8f7020..37d8b9c 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -243,36 +243,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "6.6.5"
+    LUCENE_VERS = "7.7.3"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
+        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
+        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
+        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
+        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
+        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
     )
 
     # JGit's transitive dependencies