Merge "Migrate gr-diff-host_test from js to ts"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 2e203e4..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.
@@ -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/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/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/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/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 75bb550..c6263e2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -190,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;
@@ -400,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);
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/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/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/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/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/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/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/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_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 45a6530..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'
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 66df866..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
@@ -24,7 +24,6 @@
   listenOnce,
   mockPromise,
   query,
-  spyRestApi,
   stubRestApi,
 } from '../../../test/test-utils';
 import {
@@ -32,6 +31,7 @@
   CommitId,
   EditPatchSetNum,
   NumericChangeId,
+  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RepoName,
@@ -47,16 +47,17 @@
   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 basicFixture = fixtureFromElement('gr-file-list');
@@ -70,9 +71,9 @@
 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;
     }, {});
 }
 
@@ -81,7 +82,7 @@
 
   let saveStub: sinon.SinonStub;
 
-  suite('basic tests', () => {
+  suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -93,12 +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.
       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,
@@ -107,6 +118,9 @@
       saveStub = sinon
         .stub(element, '_saveReviewedState')
         .callsFake(() => Promise.resolve());
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to complete.
+      await flush();
     });
 
     test('renders', () => {
@@ -117,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">
@@ -127,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
@@ -211,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"
@@ -221,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>
@@ -231,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">
@@ -248,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">
@@ -259,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"
@@ -298,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
@@ -329,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,
@@ -368,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,
@@ -404,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,
@@ -430,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,
@@ -454,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,
@@ -475,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,
@@ -502,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',
@@ -528,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%)'},
@@ -544,7 +502,7 @@
 
       for (const item of table) {
         assert.equal(
-          element._formatPercentage(item.size, item.delta),
+          element.formatPercentage(item.size, item.delta),
           item.display
         );
       }
@@ -567,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,
@@ -793,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},
@@ -819,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', () => {
@@ -833,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);
@@ -895,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);
       });
 
@@ -963,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();
@@ -1007,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,
@@ -1045,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);
@@ -1075,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');
@@ -1097,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},
@@ -1108,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},
@@ -1145,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;
@@ -1165,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.
@@ -1176,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;
@@ -1192,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;
@@ -1221,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'
@@ -1230,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(
@@ -1242,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,
@@ -1262,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 = [
@@ -1326,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.
@@ -1338,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
@@ -1399,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
@@ -1427,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.
@@ -1450,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},
@@ -1471,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'));
@@ -1486,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 = {
@@ -1503,7 +1536,7 @@
       };
       element.reload();
 
-      assert.isTrue(element._loading);
+      assert.isTrue(element.loading);
 
       element.loadingTask!.flush();
 
@@ -1545,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,
@@ -1571,7 +1605,7 @@
             'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
           });
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         const message = queryAndAssert(
           element,
@@ -1582,7 +1616,7 @@
 
       test('displays button for navigating to parent 1 base', async () => {
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         queryAndAssert(element, '.showParentButton');
       });
@@ -1602,9 +1636,9 @@
             },
           });
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
-        assert.deepEqual(element._cleanlyMergedOldPaths, [
+        assert.deepEqual(element.cleanlyMergedOldPaths, [
           'cleanlyMergedFileOldName.js',
         ]);
       });
@@ -1615,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'));
@@ -1627,7 +1661,7 @@
           patchNum: EditPatchSetNum,
         };
         await element.reload();
-        await flush();
+        await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
@@ -1640,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();
     });
 
@@ -1663,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();
     });
 
@@ -1686,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();
@@ -1709,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();
@@ -1730,7 +1754,7 @@
   });
 
   suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
+    test('computeSizeBarLayout', async () => {
       const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
@@ -1739,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,
@@ -1787,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', () => {
@@ -1825,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,
@@ -1846,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 ');
     });
   });
 
@@ -1949,7 +1974,7 @@
         await setupDiff(diffs[i]);
       }
 
-      element._updateDiffCursor();
+      element.updateDiffCursor();
       element.diffCursor.handleDiffUpdate();
       return diffs;
     }
@@ -1965,21 +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.
       element = basicFixture.instantiate();
-      element.diffPrefs = {} as DiffPreferencesInfo;
+      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,
@@ -1995,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,
@@ -2004,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();
 
@@ -2025,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')
       );
@@ -2033,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')
       );
@@ -2045,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.
@@ -2064,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();
@@ -2081,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')
       );
@@ -2089,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')
       );
@@ -2107,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'
@@ -2116,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', () => {
@@ -2194,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 () => {
@@ -2219,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/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index df2ba28..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
@@ -319,13 +319,6 @@
     );
   }
 
-  override ready() {
-    super.ready();
-    if (this._canReload()) {
-      this.reload();
-    }
-  }
-
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
@@ -394,7 +387,6 @@
       // assets in parallel.
       const layerPromise = this.initLayers();
       const diff = await this._getDiff();
-      this.subscribeToChecks();
       this._loadedWhitespaceLevel = whitespaceLevel;
       this._reportDiff(diff);
 
@@ -412,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) {
@@ -756,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 (
@@ -1166,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/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/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index 40d4e7f..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 */
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);